diff --git a/.github/workflows/test-unit.yml b/.github/workflows/test-unit.yml index 57ffe28a4b5..a32b5ebb2a8 100644 --- a/.github/workflows/test-unit.yml +++ b/.github/workflows/test-unit.yml @@ -213,7 +213,6 @@ jobs: test-path: >- tests/local_testing/test_cache_preset_key.py tests/local_testing/test_caching_handler.py - tests/local_testing/test_prompt_caching.py tests/local_testing/test_responses_stream_cache_keys.py tests/local_testing/test_unit_test_caching.py workers: 2 diff --git a/litellm-rust/Cargo.lock b/litellm-rust/Cargo.lock index 88dc837dd95..ea98a5f6b06 100644 --- a/litellm-rust/Cargo.lock +++ b/litellm-rust/Cargo.lock @@ -2001,6 +2001,26 @@ dependencies = [ "tokio", ] +[[package]] +name = "litellm-callbacks" +version = "0.1.0" +dependencies = [ + "rstest", + "serde_json", + "tokio", +] + +[[package]] +name = "litellm-callbacks-legacy" +version = "0.1.0" +dependencies = [ + "litellm-callbacks", + "litellm-host-python", + "pyo3", + "rstest", + "serde_json", +] + [[package]] name = "litellm-core" version = "0.1.0" @@ -2015,6 +2035,7 @@ dependencies = [ "litellm-auth-aws", "litellm-auth-azure", "litellm-auth-gcp", + "litellm-callbacks", "litellm-framing", "litellm-providers", "mime_guess", @@ -2022,6 +2043,7 @@ dependencies = [ "rand 0.8.7", "reqwest 0.12.28", "rstest", + "rstest_reuse", "rustls 0.23.42", "rustls-native-certs", "serde", @@ -2053,6 +2075,21 @@ dependencies = [ "tokio", ] +[[package]] +name = "litellm-host-python" +version = "0.1.0" +dependencies = [ + "futures-util", + "litellm-callbacks", + "pyo3", + "pyo3-async-runtimes", + "pythonize", + "rstest", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "litellm-providers" version = "0.1.0" @@ -2073,29 +2110,18 @@ dependencies = [ "criterion", "futures-util", "litellm-auth", + "litellm-callbacks-legacy", "litellm-core", - "litellm-python-interop", + "litellm-host-python", "litellm-token-counter", "pyo3", "pyo3-async-runtimes", "rstest", - "serde", "serde_json", "tokio", "tokio-tungstenite", ] -[[package]] -name = "litellm-python-interop" -version = "0.1.0" -dependencies = [ - "pyo3", - "pythonize", - "rstest", - "serde", - "serde_json", -] - [[package]] name = "litellm-token-counter" version = "0.1.0" @@ -3009,6 +3035,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "rstest_reuse" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3a8fb4672e840a587a66fc577a5491375df51ddb88f2a2c2a792598c326fe14" +dependencies = [ + "quote", + "rand 0.8.7", + "syn 2.0.119", +] + [[package]] name = "rustc-hash" version = "2.1.3" diff --git a/litellm-rust/Cargo.toml b/litellm-rust/Cargo.toml index 33cbd4f8b12..851ef91a1fb 100644 --- a/litellm-rust/Cargo.toml +++ b/litellm-rust/Cargo.toml @@ -9,8 +9,9 @@ license = "MIT" repository = "https://github.com/BerriAI/litellm" [workspace.dependencies] -bytes = "1" litellm-core = { path = "crates/core" } +litellm-callbacks = { path = "crates/callbacks" } +litellm-callbacks-legacy = { path = "crates/callbacks-legacy" } litellm-framing = { path = "crates/framer" } litellm-auth = { path = "crates/auth" } litellm-auth-aws = { path = "crates/auth-aws" } @@ -20,13 +21,16 @@ litellm-providers = { path = "crates/providers" } litellm-cache = { path = "crates/cache" } litellm-cache-memory = { path = "crates/cache-memory" } litellm-token-counter = { path = "crates/token-counter" } -litellm-python-interop = { path = "crates/python-interop" } +litellm-host-python = { path = "crates/host-python" } + +bytes = "1" pyo3 = "0.29.2" pyo3-async-runtimes = { version = "0.29.0", features = ["tokio-runtime"] } pythonize = "0.29.0" rand = "0.8" reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "multipart", "rustls-tls", "http2", "stream"] } rstest = "0.26.1" +rstest_reuse = "0.7.0" rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } rustls-native-certs = "0.8" serde = { version = "1.0", features = ["derive"] } diff --git a/litellm-rust/crates/auth-azure/src/resolve.rs b/litellm-rust/crates/auth-azure/src/resolve.rs index 660a95b79d8..4e18cbb89aa 100644 --- a/litellm-rust/crates/auth-azure/src/resolve.rs +++ b/litellm-rust/crates/auth-azure/src/resolve.rs @@ -657,4 +657,49 @@ mod tests { assert!(matches!(error, Error::CredentialChain(errors) if errors.len() == 2)); } + + #[derive(Debug)] + struct CallerToken(&'static str); + + impl litellm_auth::TokenProvider for CallerToken { + fn acquire(&self) -> litellm_auth::TokenFuture<'_> { + Box::pin(async move { + Ok(ResolvedCredential::AccessToken { + token: SecretValue::new(self.0), + expires_on: None, + }) + }) + } + } + + fn caller_inputs(token: &'static str) -> AzureAuthInputs { + let params = json!({"azure_ad_token": "static-token"}); + AzureAuthInputs { + azure_ad_token_provider: Some(litellm_auth::TokenProviderHandle::new(Arc::new( + CallerToken(token), + ))), + ..AzureAuthInputs::from_optional_params(params.as_object().unwrap()).unwrap() + } + } + + #[tokio::test] + async fn caller_token_is_chosen_over_supplied_static_token() { + let credential = AzureAuthService::default() + .get_azure_ad_token(&caller_inputs("caller-token"), &|_| None) + .await + .unwrap() + .unwrap(); + + assert_eq!(credential.value().secret().expose(), "caller-token"); + } + + #[tokio::test] + async fn empty_caller_token_is_rejected() { + let error = AzureAuthService::default() + .get_azure_ad_token(&caller_inputs(""), &|_| None) + .await + .unwrap_err(); + + assert!(matches!(error, Error::EmptyAzureToken)); + } } diff --git a/litellm-rust/crates/auth/src/credential.rs b/litellm-rust/crates/auth/src/credential.rs index 6721eb67a35..8ed1867622a 100644 --- a/litellm-rust/crates/auth/src/credential.rs +++ b/litellm-rust/crates/auth/src/credential.rs @@ -9,21 +9,6 @@ use crate::Error; use super::{ResolvedCredential, SecretValue, TokenProviderHandle}; -pub fn credential_index(requested: &str, names: &[String]) -> Option { - names.iter().position(|name| name == requested) -} - -pub fn credential_default_fields<'a>( - supplied: &[String], - credential_fields: &'a [String], -) -> Vec<&'a str> { - credential_fields - .iter() - .filter(|name| !supplied.contains(name)) - .map(String::as_str) - .collect() -} - #[derive(Clone, Debug, PartialEq, Eq)] pub enum CredentialFileRef { Path(PathBuf), diff --git a/litellm-rust/crates/auth/src/lib.rs b/litellm-rust/crates/auth/src/lib.rs index 7a24d2acf70..c8d73c239b0 100644 --- a/litellm-rust/crates/auth/src/lib.rs +++ b/litellm-rust/crates/auth/src/lib.rs @@ -47,7 +47,6 @@ impl Sourced { pub use credential::{ CredentialFileRef, CredentialLookup, CredentialLookupFuture, CredentialPlan, CredentialPlanResolution, CredentialRef, CredentialResolver, CredentialResolverHandle, - credential_default_fields, credential_index, }; pub use error::Error; pub use http::{CredentialPlacement, RequestAuth}; diff --git a/litellm-rust/crates/callbacks-legacy/AGENTS.md b/litellm-rust/crates/callbacks-legacy/AGENTS.md new file mode 100644 index 00000000000..e4762d3037a --- /dev/null +++ b/litellm-rust/crates/callbacks-legacy/AGENTS.md @@ -0,0 +1,17 @@ +- Target invariants, not completion claims +- Keep this crate the legacy `@client` wrapper as the native call sees it, and nothing else: the `Logging` contract (`function_setup`, the deployment hooks, `pre_call`/`post_call`, the sync and async success and failure fan-out, the deferred proxy release, the argument sharing those callbacks rely on) plus the kwargs rewrites the wrapper makes on the way in (credential-name inheritance, the budget and retry-count limits) + - The driver in `litellm-host-python`, the routes and core see one `CallbackAdapter`; they never learn which Python objects consume a call + - `PublicCall` is the caller's call as `Logging` sees it: the positional arguments, the keyword view as the legacy path rewrites it (setup, deployment hook, prepare) and the bound request object whose attributes back keywords the caller omitted; routes hand it over through `run_legacy_call` and keep no copy +- `setup` decides once who owns the `Logging` instance and returns it as `CallSetup.bridge_owned`; `PythonLogger` carries it and nothing on the instance records it + - A logger the caller passed as `litellm_logging_obj` is caller-owned and observed in full, because the caller reads it after the call; the proxy is the live case + - A logger `function_setup` built for this call is bridge-owned, so each fan-out phase is skipped when `callbacks_needed` finds no registry, dynamic callback, `logger_fn` or debug switch for it; cost, timing and response metadata still run +- Callbacks receive the caller's own objects and may mutate them; this crate alone carries that obligation + - Retain complete boundary arguments, opaque unknown values, aliases, omitted/default distinctions and deliberate copies; preserve the established deployment-hook kwargs view + - Re-alias every `passthrough_fields` body key to the caller's object before `pre_call`; a keyword wins over the request attribute even when it is an explicit `None` + - Retain independently captured body/header roots from `pre_call` to `post_call`; in-place mutation reaches the wire, envelope field replacement is visible to later callbacks only + - A later kind of callback host (WASM, in-process Rust) has none of these obligations, so they stay out of `litellm-callbacks`, `litellm-host-python` and the bridge; the only facts that cross from the route are the prepared keyword view and `RequestContext.passthrough_fields` +- Success and failure handlers receive the exact selected public response or exception; logging projections, redaction and snapshots keep their own copy contracts + - Ordinary failure-handler errors cannot suppress the other eligible family or replace the mapped provider error; a cancellation ends the call with no further dispatch + - Dispatch errors never replay provider work or trigger the opposite outcome; the proxy's acceptance or rejection releases deferred success at most once + - Delivery follows the registry, not the callable's type: direct, awaited, executor-submitted, logging-worker and deferred paths stay distinct +- Traverse every retained Python edge; `close` is idempotent and restores the correlation context once diff --git a/litellm-rust/crates/callbacks-legacy/Cargo.toml b/litellm-rust/crates/callbacks-legacy/Cargo.toml new file mode 100644 index 00000000000..96c9c9ed560 --- /dev/null +++ b/litellm-rust/crates/callbacks-legacy/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "litellm-callbacks-legacy" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +autotests = false + +[dependencies] +litellm-callbacks.workspace = true +litellm-host-python.workspace = true +pyo3.workspace = true + +[dev-dependencies] +rstest.workspace = true +serde_json.workspace = true diff --git a/litellm-rust/crates/callbacks-legacy/src/adapter.rs b/litellm-rust/crates/callbacks-legacy/src/adapter.rs new file mode 100644 index 00000000000..df346506094 --- /dev/null +++ b/litellm-rust/crates/callbacks-legacy/src/adapter.rs @@ -0,0 +1,385 @@ +//! The legacy `Logging` contract as one adapter: every event and interception the driver +//! raises is answered with the same `Logging` calls, in the same order, as the Python +//! `@client` path makes them. + +use litellm_callbacks::event::{CallEvent, FailureOrigin, RequestContext, Timing, WireRequest}; +use litellm_host_python::{ + AdapterStep, CallbackAdapter, PublicValue, from_py, missing_state, to_py, +}; +use pyo3::{ + exceptions::{PyBaseException, PyException}, + gc::{PyTraverseError, PyVisit}, + prelude::*, + types::PyDict, +}; + +use crate::{ + DeploymentHooks, LegacyCallbacks, PublicCall, PythonLogger, + deferred::{PendingLogging, PendingSuccess}, + finalize, is_internal_call, prepare, setup, +}; + +/// What the legacy contract needs to know about the route it is logging. +#[derive(Clone, Copy, Debug)] +pub struct LegacySurface { + pub call_type: &'static str, + /// What `Logging.pre_call` is told the input was. + pub input_description: &'static str, +} + +enum Pending { + DeploymentPreCall, + DeploymentPostCall, + DeploymentFailure, + AsyncFailure, +} + +pub struct LegacyLogging { + surface: LegacySurface, + call: PublicCall, + logger: Option, + start: Py, + end: Option>, + response: Option>, + error: Option>, + body: Option>, + headers: Option>, + asynchronous: bool, + internal: bool, + pending: Option, +} + +fn datetime(py: Python<'_>, epoch_seconds: f64) -> PyResult> { + py.import("datetime")? + .getattr("datetime")? + .call_method1("fromtimestamp", (epoch_seconds,)) + .map(Bound::unbind) +} + +fn is_cancellation(py: Python<'_>, error: &PyErr) -> bool { + !error.is_instance_of::(py) +} + +impl LegacyLogging { + pub fn new( + py: Python<'_>, + surface: LegacySurface, + call: PublicCall, + asynchronous: bool, + ) -> Self { + Self { + surface, + call, + logger: None, + start: py.None(), + end: None, + response: None, + error: None, + body: None, + headers: None, + asynchronous, + internal: false, + pending: None, + } + } + + /// Deployment hooks are awaited, and Python's synchronous `@client` wrapper never + /// runs them. + fn deployment_hooks(&self, py: Python<'_>) -> PyResult { + Ok(self.asynchronous && DeploymentHooks::needed(py)?) + } + + fn logger(&self) -> PyResult<&PythonLogger> { + self.logger.as_ref().ok_or_else(|| { + pyo3::exceptions::PyRuntimeError::new_err("call logging is not initialized") + }) + } + + fn prepare(&mut self, py: Python<'_>) -> PyResult { + let prepared = prepare(py, self.call.kwargs().bind(py), self.logger()?)?.unbind(); + self.call.set_kwargs(prepared); + Ok(AdapterStep::Arguments(self.call.kwargs().clone_ref(py))) + } + + fn finalize(&mut self, py: Python<'_>) -> PyResult { + finalize( + py, + &self.response, + self.logger()?, + self.call.kwargs(), + &self.start, + &self.end, + )?; + self.response + .as_ref() + .map(|response| AdapterStep::Response(response.clone_ref(py))) + .ok_or_else(missing_state) + } + + fn dispatch_success(&self, py: Python<'_>) -> PyResult<()> { + match self.try_dispatch_success(py) { + Err(error) if error.is_instance_of::(py) => { + error.write_unraisable(py, self.logger.as_ref().map(|logger| logger.object(py))); + Ok(()) + } + result => result, + } + } + + fn try_dispatch_success(&self, py: Python<'_>) -> PyResult<()> { + let logger = self.logger()?; + let pending = || PendingSuccess { + logger: logger.clone_ref(py), + response: self.response.as_ref().map(|value| value.clone_ref(py)), + start: self.start.clone_ref(py), + end: self.end.as_ref().map(|value| value.clone_ref(py)), + }; + if !self.asynchronous { + return pending().sync(py); + } + if !self.internal + && self + .call + .kwargs() + .bind(py) + .get_item("fallbacks")? + .is_none_or(|value| value.is_none()) + { + if !logger.callbacks_needed(py, "async_success")? { + logger.success_bookkeeping(py, &self.response, &self.start, &self.end, true)?; + } else if logger.defers_async_logging(py) { + let pending = Py::new( + py, + PendingLogging { + pending: Some(pending()), + }, + )?; + logger.defer_success(py, pending.bind(py).as_any())?; + } else { + pending().asynchronous(py)?; + } + } + logger.sync_success_for_async_call(py, &self.response, &self.start, &self.end) + } + + /// The sync failure handler, then the async one for async calls. Ordinary handler + /// errors never replace the selected failure or suppress the other family; a + /// cancellation does end the call. + fn dispatch_failure(&mut self, py: Python<'_>) -> PyResult { + let (Some(logger), Some(error)) = (&self.logger, &self.error) else { + return Ok(AdapterStep::Done); + }; + if self.asynchronous && self.internal { + return Ok(AdapterStep::Done); + } + if let Err(failure) = logger.failure(py, error, &self.start, &self.end, false) + && is_cancellation(py, &failure) + { + return Err(failure); + } + if !self.asynchronous { + return Ok(AdapterStep::Done); + } + match logger.failure(py, error, &self.start, &self.end, true) { + Ok(Some(awaitable)) => { + self.pending = Some(Pending::AsyncFailure); + Ok(AdapterStep::Await(awaitable)) + } + Ok(None) => Ok(AdapterStep::Done), + Err(failure) if is_cancellation(py, &failure) => Err(failure), + Err(_) => Ok(AdapterStep::Done), + } + } +} + +impl CallbackAdapter for LegacyLogging { + fn begin( + &mut self, + py: Python<'_>, + arguments: Py, + started_at: f64, + ) -> PyResult { + self.call.set_kwargs(arguments); + self.start = datetime(py, started_at)?; + self.internal = is_internal_call(py)?; + let result = setup( + py, + self.surface.call_type, + self.call.args(), + self.call.kwargs(), + &self.start, + self.asynchronous, + )?; + self.logger = Some(result.logger()?); + self.call.set_kwargs(result.kwargs()?); + if self.deployment_hooks(py)? { + self.pending = Some(Pending::DeploymentPreCall); + return Ok(AdapterStep::Await(DeploymentHooks::before_call( + py, + self.call.kwargs(), + self.surface.call_type, + )?)); + } + self.prepare(py) + } + + fn before_send( + &mut self, + py: Python<'_>, + wire: Box, + context: &RequestContext, + ) -> PyResult { + let logger = self.logger()?; + logger.update_from_kwargs(py, self.call.kwargs(), &wire, context)?; + if !logger.callbacks_needed(py, "payload")? { + logger.record_api_call_start(py)?; + return Ok(AdapterStep::Wire(wire)); + } + let body = to_py(py, &wire.body)? + .into_bound(py) + .cast_into::()?; + for name in context.passthrough_fields.iter() { + if let Some(value) = self.call.lookup(py, name)? { + body.set_item(name, value)?; + } + } + let headers = PyDict::new(py); + for (name, value) in &wire.headers { + headers.set_item(name, value)?; + } + self.body = Some(body.clone().unbind()); + self.headers = Some(headers.clone().unbind()); + let api_key = self.call.lookup(py, "api_key")?; + self.logger()?.pre_call( + py, + self.surface.input_description, + api_key.as_ref(), + &body, + &headers, + &wire.url, + )?; + let headers = headers + .iter() + .map(|(name, value)| Ok((name.extract::()?, value.extract::()?))) + .collect::>>()?; + Ok(AdapterStep::Wire(Box::new(WireRequest { + body: from_py(&body)?, + headers, + ..*wire + }))) + } + + fn after_success( + &mut self, + py: Python<'_>, + response: Py, + timing: Timing, + ) -> PyResult { + self.end = Some(datetime(py, timing.end_time)?); + self.response = Some(response); + if self.deployment_hooks(py)? { + self.pending = Some(Pending::DeploymentPostCall); + return Ok(AdapterStep::Await(DeploymentHooks::after_success( + py, + self.call.kwargs(), + &self.response, + self.surface.call_type, + )?)); + } + self.finalize(py) + } + + fn emit( + &mut self, + py: Python<'_>, + event: &CallEvent, + public: Option>, + ) -> PyResult { + match (event, public) { + (CallEvent::ResponseReceived { raw }, _) => { + let logger = self.logger()?; + if logger.callbacks_needed(py, "payload")? { + logger.post_call(py, &raw.body, self.body.as_ref(), self.headers.as_ref())?; + } + Ok(AdapterStep::Done) + } + (CallEvent::Succeeded { timing }, Some(PublicValue::Response(response))) => { + self.end = Some(datetime(py, timing.end_time)?); + self.response = Some(response.clone_ref(py)); + self.dispatch_success(py)?; + Ok(AdapterStep::Done) + } + (CallEvent::Failed { timing, origin }, Some(PublicValue::Error(error))) => { + self.end = Some(datetime(py, timing.end_time)?); + self.error = Some(error.clone_ref(py).into_value(py)); + if *origin == FailureOrigin::Call + && self.logger.is_some() + && self.deployment_hooks(py)? + { + let error = self.error.as_ref().ok_or_else(missing_state)?; + self.pending = Some(Pending::DeploymentFailure); + return Ok(AdapterStep::Await(DeploymentHooks::after_failure( + py, + self.call.kwargs(), + error, + self.surface.call_type, + )?)); + } + self.dispatch_failure(py) + } + _ => Err(missing_state()), + } + } + + fn resume(&mut self, py: Python<'_>, result: PyResult>) -> PyResult { + match self.pending.take().ok_or_else(missing_state)? { + Pending::DeploymentPreCall => { + self.call + .set_kwargs(result?.into_bound(py).cast_into::()?.unbind()); + self.prepare(py) + } + Pending::DeploymentPostCall => { + self.response = Some(result?); + self.finalize(py) + } + Pending::DeploymentFailure => self.dispatch_failure(py), + Pending::AsyncFailure => match result { + Err(failure) if is_cancellation(py, &failure) => Err(failure), + _ => Ok(AdapterStep::Done), + }, + } + } + + fn close(&mut self, py: Python<'_>) { + if let Some(logger) = self.logger.take() + && let Err(error) = logger.restore_context(py) + { + error.write_unraisable(py, None); + } + self.body = None; + self.headers = None; + } + + fn traverse(&self, visit: &PyVisit<'_>) -> Result<(), PyTraverseError> { + self.call.traverse(visit)?; + if let Some(logger) = &self.logger { + logger.traverse(visit)?; + } + visit.call(&self.start)?; + visit.call(&self.end)?; + visit.call(&self.response)?; + visit.call(&self.error)?; + visit.call(&self.body)?; + visit.call(&self.headers) + } +} + +#[cfg(test)] +#[path = "../tests/deployment_hooks.rs"] +mod deployment_hooks_tests; +#[cfg(test)] +#[path = "../tests/payload.rs"] +mod payload_tests; +#[cfg(test)] +#[path = "../tests/terminal.rs"] +mod terminal_tests; diff --git a/litellm-rust/crates/callbacks-legacy/src/call.rs b/litellm-rust/crates/callbacks-legacy/src/call.rs new file mode 100644 index 00000000000..59090ee8d60 --- /dev/null +++ b/litellm-rust/crates/callbacks-legacy/src/call.rs @@ -0,0 +1,179 @@ +//! The caller's public call as the legacy `Logging` contract sees it. Legacy callbacks +//! receive these exact objects and may mutate them, so the call keeps them for its whole +//! lifetime. No other callback host has that obligation, which is why nothing outside +//! this crate holds them. + +use litellm_callbacks::{machine::Machine, route::Route}; +use litellm_host_python::{RouteHost, run_call}; +use pyo3::{ + gc::{PyTraverseError, PyVisit}, + prelude::*, + types::{PyDict, PyTuple}, +}; + +use crate::{LegacyLogging, LegacySurface}; + +pub struct PublicCall { + args: Py, + kwargs: Py, + request: Py, +} + +impl PublicCall { + /// Copies the keyword arguments once, so the legacy path's rewrites never reach the + /// caller's own dict while every value keeps its identity. + pub fn capture( + request: &Bound<'_, PyAny>, + args: &Bound<'_, PyTuple>, + kwargs: &Bound<'_, PyDict>, + ) -> PyResult { + Ok(Self { + args: args.clone().unbind(), + kwargs: kwargs.copy()?.unbind(), + request: request.clone().unbind(), + }) + } + + pub(crate) fn args(&self) -> &Py { + &self.args + } + + /// The keyword view the legacy path currently reads: the caller's copy until + /// `function_setup`, then each rewrite (setup, deployment hook, prepare) in turn. + pub(crate) fn kwargs(&self) -> &Py { + &self.kwargs + } + + pub(crate) fn set_kwargs(&mut self, kwargs: Py) { + self.kwargs = kwargs; + } + + pub(crate) fn lookup<'py>( + &self, + py: Python<'py>, + name: &str, + ) -> PyResult>> { + lookup(self.kwargs.bind(py), self.request.bind(py), name) + } + + pub(crate) fn traverse(&self, visit: &PyVisit<'_>) -> Result<(), PyTraverseError> { + visit.call(&self.args)?; + visit.call(&self.kwargs)?; + visit.call(&self.request) + } +} + +/// The caller's own object for a public argument, as every legacy reader resolves it: the +/// keyword if given, even an explicit `None`, else the bound request's attribute. A route +/// host projecting from the prepared keyword view uses the same rule, so the callbacks +/// and the provider see one object per argument. +pub fn lookup<'py>( + kwargs: &Bound<'py, PyDict>, + request: &Bound<'py, PyAny>, + name: &str, +) -> PyResult>> { + if let Some(value) = kwargs.get_item(name)? { + return Ok(Some(value)); + } + request.getattr_opt(name) +} + +/// Runs one native call under the legacy `Logging` contract: the route host projects from +/// the keyword view the contract prepares, and the contract observes the call. +pub fn run_legacy_call( + py: Python<'_>, + surface: LegacySurface, + call: PublicCall, + machine: M, + route: H, + asynchronous: bool, +) -> PyResult> +where + H: RouteHost + 'static, + M: Machine::Response> + 'static, +{ + let arguments = call.kwargs.clone_ref(py); + run_call( + py, + machine, + route, + Box::new(LegacyLogging::new(py, surface, call, asynchronous)), + arguments, + asynchronous, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn capture<'py>(py: Python<'py>, source: &std::ffi::CStr) -> (PublicCall, Bound<'py, PyDict>) { + let locals = PyDict::new(py); + py.run(source, Some(&locals), Some(&locals)).unwrap(); + let request = locals.get_item("request").unwrap().unwrap(); + let kwargs = locals + .get_item("kwargs") + .unwrap() + .unwrap() + .cast_into::() + .unwrap(); + let call = PublicCall::capture(&request, &PyTuple::empty(py), &kwargs).unwrap(); + (call, locals) + } + + #[test] + fn lookup_prefers_the_keyword_even_when_none_and_falls_back_to_the_request() { + Python::initialize(); + Python::attach(|py| { + let (call, locals) = capture( + py, + c" +key = object() +document = {'type': 'document_url'} +class Request: + api_key = 'from-request' + api_base = 'from-request' + document = document +request = Request() +kwargs = {'api_key': key, 'api_base': None} +", + ); + let key = locals.get_item("key").unwrap().unwrap(); + let document = locals.get_item("document").unwrap().unwrap(); + assert!(call.lookup(py, "api_key").unwrap().unwrap().is(&key)); + assert!(call.lookup(py, "api_base").unwrap().unwrap().is_none()); + assert!(call.lookup(py, "document").unwrap().unwrap().is(&document)); + assert!(call.lookup(py, "model").unwrap().is_none()); + }); + } + + #[test] + fn capture_copies_the_keyword_dict_without_copying_its_values() { + Python::initialize(); + Python::attach(|py| { + let (call, locals) = capture( + py, + c" +pages = [0] +class Request: + pass +request = Request() +kwargs = {'pages': pages} +", + ); + let caller = locals + .get_item("kwargs") + .unwrap() + .unwrap() + .cast_into::() + .unwrap(); + call.kwargs() + .bind(py) + .set_item("litellm_call_id", "call") + .unwrap(); + assert!(!caller.contains("litellm_call_id").unwrap()); + let pages = locals.get_item("pages").unwrap().unwrap(); + assert!(call.lookup(py, "pages").unwrap().unwrap().is(&pages)); + }); + } +} diff --git a/litellm-rust/crates/callbacks-legacy/src/callbacks.rs b/litellm-rust/crates/callbacks-legacy/src/callbacks.rs new file mode 100644 index 00000000000..aa586013e75 --- /dev/null +++ b/litellm-rust/crates/callbacks-legacy/src/callbacks.rs @@ -0,0 +1,404 @@ +//! Callback fan-out over litellm's `Logging` object: which callbacks are registered, +//! the deferred and worker-submitted success paths, and the sync-callbacks-for-async-calls +//! duplication. All of it expires with the legacy callback contract. + +use litellm_callbacks::event::{RequestContext, WireRequest}; +use litellm_host_python::to_py; +use pyo3::{exceptions::PyBaseException, prelude::*, types::PyDict}; + +use crate::logger::PythonLogger; + +pub trait LegacyCallbacks { + fn callbacks_needed(&self, py: Python<'_>, phase: &str) -> PyResult; + + /// `Logging.update_from_kwargs`: what the logger is told about the request it is + /// about to see, with consumed credentials redacted. + fn update_from_kwargs( + &self, + py: Python<'_>, + kwargs: &Py, + wire: &WireRequest, + context: &RequestContext, + ) -> PyResult<()>; + + fn record_api_call_start(&self, py: Python<'_>) -> PyResult<()>; + + /// `Logging.pre_call`, or its payload-free shortcut when no input callback listens. + fn pre_call( + &self, + py: Python<'_>, + input: &str, + api_key: Option<&Bound<'_, PyAny>>, + body: &Bound<'_, PyDict>, + headers: &Bound<'_, PyDict>, + url: &str, + ) -> PyResult<()>; + + /// `Logging.post_call`, or its payload-free shortcut when no input callback listens. + fn post_call( + &self, + py: Python<'_>, + original_response: &str, + body: Option<&Py>, + headers: Option<&Py>, + ) -> PyResult<()>; + + fn defers_async_logging(&self, py: Python<'_>) -> bool; + + fn defer_success(&self, py: Python<'_>, pending: &Bound<'_, PyAny>) -> PyResult<()>; + + fn sync_success_for_async_call( + &self, + py: Python<'_>, + response: &Option>, + start: &Py, + end: &Option>, + ) -> PyResult<()>; + + fn failure( + &self, + py: Python<'_>, + error: &Py, + start: &Py, + end: &Option>, + asynchronous: bool, + ) -> PyResult>>; + + fn submit_success( + &self, + py: Python<'_>, + response: &Option>, + start: &Py, + end: &Option>, + ) -> PyResult<()>; + + fn enqueue_success( + &self, + py: Python<'_>, + response: &Option>, + start: &Py, + end: &Option>, + ) -> PyResult<()>; +} + +impl LegacyCallbacks for PythonLogger { + fn callbacks_needed(&self, py: Python<'_>, phase: &str) -> PyResult { + if !self.bridge_owned() { + return Ok(true); + } + py.import("litellm.rust_bridge.legacy_callbacks")? + .getattr("callbacks_needed")? + .call1((self.object(py), phase))? + .extract() + } + + fn update_from_kwargs( + &self, + py: Python<'_>, + kwargs: &Py, + wire: &WireRequest, + context: &RequestContext, + ) -> PyResult<()> { + let secret_fields: Vec<&str> = context.secret_fields.iter().map(String::as_str).collect(); + let update = PyDict::new(py); + update.set_item("kwargs", redact(py, kwargs.bind(py), &secret_fields)?)?; + update.set_item("model", &context.model)?; + update.set_item( + "optional_params", + redact( + py, + &to_py(py, &context.optional_params)? + .into_bound(py) + .cast_into::()?, + &secret_fields, + )?, + )?; + let params = PyDict::new(py); + params.set_item( + "litellm_call_id", + kwargs.bind(py).get_item("litellm_call_id")?, + )?; + params.set_item("api_base", &wire.url)?; + for name in ["logger_fn", "litellm_request_debug"] { + if let Some(value) = kwargs.bind(py).get_item(name)? { + params.set_item(name, value)?; + } + } + for name in custom_pricing_fields(py)? { + if let Some(value) = kwargs.bind(py).get_item(&name)? + && !value.is_none() + { + params.set_item(name, value)?; + } + } + update.set_item("litellm_params", params)?; + update.set_item("custom_llm_provider", &context.custom_llm_provider)?; + self.object(py) + .call_method("update_from_kwargs", (), Some(&update))?; + Ok(()) + } + + fn record_api_call_start(&self, py: Python<'_>) -> PyResult<()> { + self.object(py).call_method0("record_api_call_start_time")?; + Ok(()) + } + + fn pre_call( + &self, + py: Python<'_>, + input: &str, + api_key: Option<&Bound<'_, PyAny>>, + body: &Bound<'_, PyDict>, + headers: &Bound<'_, PyDict>, + url: &str, + ) -> PyResult<()> { + let additional = PyDict::new(py); + additional.set_item("complete_input_dict", body)?; + additional.set_item("headers", headers)?; + additional.set_item("api_base", url)?; + let kwargs = PyDict::new(py); + kwargs.set_item("input", input)?; + kwargs.set_item("api_key", api_key)?; + kwargs.set_item("additional_args", &additional)?; + if self.callbacks_needed(py, "input")? { + self.object(py).call_method("pre_call", (), Some(&kwargs))?; + } else { + self.object(py) + .call_method("_pre_call", (), Some(&kwargs))?; + self.record_api_call_start(py)?; + } + Ok(()) + } + + fn post_call( + &self, + py: Python<'_>, + original_response: &str, + body: Option<&Py>, + headers: Option<&Py>, + ) -> PyResult<()> { + let additional = PyDict::new(py); + additional.set_item("complete_input_dict", body)?; + additional.set_item("headers", headers)?; + if self.callbacks_needed(py, "input")? { + let kwargs = PyDict::new(py); + kwargs.set_item("original_response", original_response)?; + kwargs.set_item("additional_args", &additional)?; + self.object(py) + .call_method("post_call", (), Some(&kwargs))?; + } else { + let response = py + .import("json")? + .call_method1("dumps", (original_response,))?; + self.object(py).call_method1( + "record_post_call", + (response, py.None(), py.None(), additional), + )?; + } + Ok(()) + } + fn defers_async_logging(&self, py: Python<'_>) -> bool { + self.object(py) + .getattr("_defer_async_logging") + .is_ok_and(|value| value.is_truthy().unwrap_or(false)) + } + + fn defer_success(&self, py: Python<'_>, pending: &Bound<'_, PyAny>) -> PyResult<()> { + self.object(py).setattr("_native_pending_logging", pending) + } + + fn sync_success_for_async_call( + &self, + py: Python<'_>, + response: &Option>, + start: &Py, + end: &Option>, + ) -> PyResult<()> { + if !self.callbacks_needed(py, "sync_success_async")? { + return Ok(()); + } + self.object(py).call_method1( + "handle_sync_success_callbacks_for_async_calls", + (response, start, end), + )?; + Ok(()) + } + + fn failure( + &self, + py: Python<'_>, + error: &Py, + start: &Py, + end: &Option>, + asynchronous: bool, + ) -> PyResult>> { + if !self.callbacks_needed( + py, + if asynchronous { + "async_failure" + } else { + "sync_failure" + }, + )? { + py.import("litellm.rust_bridge.legacy_callbacks")? + .getattr("failure_bookkeeping")? + .call1((self.object(py), error, start, end, asynchronous))?; + return Ok(None); + } + let trace = py + .import("traceback")? + .getattr("format_exception")? + .call1((error,))?; + let trace = pyo3::types::PyString::new(py, "").call_method1("join", (trace,))?; + let value = self.object(py).call_method1( + if asynchronous { + "async_failure_handler" + } else { + "failure_handler" + }, + (error, trace, start, end), + )?; + Ok(asynchronous.then(|| value.unbind())) + } + fn submit_success( + &self, + py: Python<'_>, + response: &Option>, + start: &Py, + end: &Option>, + ) -> PyResult<()> { + if !self.callbacks_needed(py, "sync_success")? { + return self.success_bookkeeping(py, response, start, end, false); + } + let context = py.import("contextvars")?.call_method0("copy_context")?; + py.import("litellm.litellm_core_utils.litellm_logging")? + .getattr("executor")? + .call_method1( + "submit", + ( + context.getattr("run")?, + self.object(py).getattr("success_handler")?, + response, + start, + end, + ), + )?; + Ok(()) + } + + fn enqueue_success( + &self, + py: Python<'_>, + response: &Option>, + start: &Py, + end: &Option>, + ) -> PyResult<()> { + if !self.callbacks_needed(py, "async_success")? { + return self.success_bookkeeping(py, response, start, end, true); + } + let context = py.import("contextvars")?.call_method0("copy_context")?; + let worker = py + .import("litellm.litellm_core_utils.logging_worker")? + .getattr("GLOBAL_LOGGING_WORKER")? + .getattr("ensure_initialized_and_enqueue")?; + let coroutine = self + .object(py) + .call_method1("async_success_handler", (response, start, end))?; + let enqueue = context.call_method1("run", (worker, &coroutine)); + if enqueue.is_err() + && let Err(error) = coroutine.call_method0("close") + { + error.write_unraisable(py, Some(&coroutine)); + } + enqueue.map(|_| ()) + } +} + +fn custom_pricing_fields(py: Python<'_>) -> PyResult> { + py.import("litellm.types.utils")? + .getattr("CustomPricingLiteLLMParams")? + .getattr("model_fields")? + .cast_into::()? + .keys() + .iter() + .map(|name| name.extract::()) + .collect() +} + +fn redact( + py: Python<'_>, + params: &Bound<'_, PyDict>, + secret_fields: &[&str], +) -> PyResult> { + let redacted = PyDict::new(py); + for (name, value) in params { + let name = name.extract::()?; + if name == "proxy_server_request" { + continue; + } + if secret_fields.contains(&name.as_str()) { + redacted.set_item(name, "****")?; + } else { + redacted.set_item(name, value)?; + } + } + Ok(redacted.unbind()) +} + +/// Proxy-internal calls skip the legacy success fan-out. +pub fn is_internal_call(py: Python<'_>) -> PyResult { + py.import("litellm._internal_context")? + .getattr("is_internal_call")? + .call_method0("get")? + .extract() +} + +#[cfg(test)] +mod tests { + use pyo3::types::PyDict; + + use super::*; + + fn logger_whose_registries_need_no_input(py: Python<'_>, bridge_owned: bool) -> PythonLogger { + let locals = PyDict::new(py); + py.run( + c" +import sys +import types +for name in ('litellm', 'litellm.rust_bridge', 'litellm.rust_bridge.legacy_callbacks'): + sys.modules.setdefault(name, types.ModuleType(name)) +legacy = sys.modules['litellm.rust_bridge.legacy_callbacks'] +legacy.callbacks_needed = lambda logger, phase: logger.needed.get(phase, True) +class Logger: + needed = {'input': False} +logger = Logger() +", + Some(&locals), + Some(&locals), + ) + .unwrap(); + PythonLogger::new( + locals.get_item("logger").unwrap().unwrap().unbind(), + bridge_owned, + ) + } + + #[test] + fn a_caller_owned_logger_is_observed_in_full() { + Python::initialize(); + Python::attach(|py| { + let logger = logger_whose_registries_need_no_input(py, false); + assert!(logger.callbacks_needed(py, "input").unwrap()); + }); + } + + #[test] + fn a_bridge_owned_logger_is_elided_where_no_registry_needs_it() { + Python::initialize(); + Python::attach(|py| { + let logger = logger_whose_registries_need_no_input(py, true); + assert!(!logger.callbacks_needed(py, "input").unwrap()); + assert!(logger.callbacks_needed(py, "payload").unwrap()); + }); + } +} diff --git a/litellm-rust/crates/callbacks-legacy/src/deferred.rs b/litellm-rust/crates/callbacks-legacy/src/deferred.rs new file mode 100644 index 00000000000..b18012f926e --- /dev/null +++ b/litellm-rust/crates/callbacks-legacy/src/deferred.rs @@ -0,0 +1,67 @@ +//! The proxy's deferred success release: the async success handler is queued only once +//! the proxy accepts the response, and at most once. + +use pyo3::{exceptions::PyException, prelude::*}; + +use crate::{LegacyCallbacks, PythonLogger}; + +pub(crate) struct PendingSuccess { + pub(crate) logger: PythonLogger, + pub(crate) response: Option>, + pub(crate) start: Py, + pub(crate) end: Option>, +} + +impl PendingSuccess { + pub(crate) fn sync(&self, py: Python<'_>) -> PyResult<()> { + self.logger + .submit_success(py, &self.response, &self.start, &self.end) + } + + pub(crate) fn asynchronous(&self, py: Python<'_>) -> PyResult<()> { + self.logger + .enqueue_success(py, &self.response, &self.start, &self.end) + } +} + +#[pyclass] +pub(crate) struct PendingLogging { + pub(crate) pending: Option, +} + +#[pymethods] +impl PendingLogging { + fn release(slf: &Bound<'_, Self>, py: Python<'_>, success: bool) -> PyResult<()> { + let pending = slf.borrow_mut().pending.take(); + if let Some(pending) = pending + && success + { + match pending.asynchronous(py) { + Err(error) if error.is_instance_of::(py) => { + error.write_unraisable(py, Some(pending.logger.object(py))); + } + result => return result, + } + } + Ok(()) + } + + fn __traverse__(&self, visit: pyo3::gc::PyVisit<'_>) -> Result<(), pyo3::gc::PyTraverseError> { + if let Some(pending) = &self.pending { + pending.logger.traverse(&visit)?; + visit.call(&pending.response)?; + visit.call(&pending.start)?; + visit.call(&pending.end)?; + } + Ok(()) + } + + fn __clear__(slf: &Bound<'_, Self>) { + let pending = slf.borrow_mut().pending.take(); + drop(pending); + } +} + +#[cfg(test)] +#[path = "../tests/deferred.rs"] +mod tests; diff --git a/litellm-rust/crates/callbacks-legacy/src/lib.rs b/litellm-rust/crates/callbacks-legacy/src/lib.rs new file mode 100644 index 00000000000..06783ac255d --- /dev/null +++ b/litellm-rust/crates/callbacks-legacy/src/lib.rs @@ -0,0 +1,27 @@ +//! The legacy `@client` wrapper as the native call sees it: litellm's `Logging` object, the +//! sync and async callback registries it fans out to, the deployment hooks, the deferred +//! proxy release, and the kwargs rewrites the wrapper makes on the way in (credential-name +//! inheritance, budget and retry-count limits). All of it sits behind one +//! [`CallbackAdapter`](litellm_host_python::CallbackAdapter), so the driver, the routes and +//! core never learn which Python object is on the other end. +//! +//! Legacy callbacks receive the caller's own objects and may mutate them. [`PublicCall`] +//! is where those objects live, and [`run_legacy_call`] is how a route hands them over +//! without keeping a copy. + +mod adapter; +mod call; +mod callbacks; +mod deferred; +mod logger; +mod preparation; +#[cfg(test)] +#[path = "../tests/support.rs"] +mod test_support; + +pub(crate) use adapter::LegacyLogging; +pub use adapter::LegacySurface; +pub use call::{PublicCall, lookup, run_legacy_call}; +pub(crate) use callbacks::{LegacyCallbacks, is_internal_call}; +pub(crate) use logger::{DeploymentHooks, PythonLogger, finalize, setup}; +pub(crate) use preparation::prepare; diff --git a/litellm-rust/crates/callbacks-legacy/src/logger.rs b/litellm-rust/crates/callbacks-legacy/src/logger.rs new file mode 100644 index 00000000000..a0e525000b8 --- /dev/null +++ b/litellm-rust/crates/callbacks-legacy/src/logger.rs @@ -0,0 +1,236 @@ +use pyo3::{ + exceptions::PyBaseException, + gc::{PyTraverseError, PyVisit}, + prelude::*, + types::{PyDict, PyTuple}, +}; + +/// The `Logging` instance one call fans out through, and who owns it. A logger the caller +/// handed in is observed in full, because the caller reads it after the call; one this +/// crate built through `function_setup` is elided wherever no registry needs it. +pub struct PythonLogger { + object: Py, + bridge_owned: bool, +} + +impl PythonLogger { + pub(crate) fn new(object: Py, bridge_owned: bool) -> Self { + Self { + object, + bridge_owned, + } + } + + pub(crate) fn object<'py>(&self, py: Python<'py>) -> &Bound<'py, PyAny> { + self.object.bind(py) + } + + pub(crate) fn bridge_owned(&self) -> bool { + self.bridge_owned + } + + pub fn clone_ref(&self, py: Python<'_>) -> Self { + Self { + object: self.object.clone_ref(py), + bridge_owned: self.bridge_owned, + } + } + + pub fn traverse(&self, visit: &PyVisit<'_>) -> Result<(), PyTraverseError> { + visit.call(&self.object) + } + + pub fn success_bookkeeping( + &self, + py: Python<'_>, + response: &Option>, + start: &Py, + end: &Option>, + asynchronous: bool, + ) -> PyResult<()> { + py.import("litellm.rust_bridge.legacy_callbacks")? + .getattr("success_bookkeeping")? + .call1((self.object(py), response, start, end, asynchronous))?; + Ok(()) + } + + pub fn restore_context(&self, py: Python<'_>) -> PyResult<()> { + py.import("litellm.utils")? + .getattr("_restore_correlation_context_if_supported")? + .call1((self.object(py),))?; + Ok(()) + } +} + +/// A bare Python object was not obtained from `setup`, so it is caller-owned. +impl FromPyObject<'_, '_> for PythonLogger { + type Error = PyErr; + + fn extract(object: Borrowed<'_, '_, PyAny>) -> PyResult { + Ok(Self::new(object.to_owned().unbind(), false)) + } +} + +pub struct SetupResult<'py>(Bound<'py, PyAny>); + +impl SetupResult<'_> { + pub fn logger(&self) -> PyResult { + let object = self.0.getattr("logger")?.unbind(); + let bridge_owned = self.0.getattr("bridge_owned")?.extract()?; + Ok(PythonLogger::new(object, bridge_owned)) + } + + pub fn kwargs(&self) -> PyResult> { + Ok(self.0.getattr("kwargs")?.extract()?) + } +} + +pub fn setup<'py>( + py: Python<'py>, + call_type: &str, + args: &Py, + kwargs: &Py, + start: &Py, + asynchronous: bool, +) -> PyResult> { + py.import("litellm.rust_bridge.legacy_callbacks")? + .getattr("setup")? + .call1((call_type, args, kwargs, start, asynchronous)) + .map(SetupResult) +} + +pub fn finalize( + py: Python<'_>, + response: &Option>, + logger: &PythonLogger, + kwargs: &Py, + start: &Py, + end: &Option>, +) -> PyResult<()> { + py.import("litellm.rust_bridge.legacy_callbacks")? + .getattr("finalize")? + .call1((response, logger.object(py), kwargs, start, end))?; + Ok(()) +} + +pub struct DeploymentHooks; + +impl DeploymentHooks { + pub fn needed(py: Python<'_>) -> PyResult { + py.import("litellm.rust_bridge.legacy_callbacks")? + .getattr("deployment_callbacks_needed")? + .call0()? + .extract() + } + + pub fn before_call( + py: Python<'_>, + kwargs: &Py, + call_type: &str, + ) -> PyResult> { + py.import("litellm.utils")? + .getattr("async_pre_call_deployment_hook")? + .call1((kwargs, call_type)) + .map(Bound::unbind) + } + + pub fn after_success( + py: Python<'_>, + kwargs: &Py, + response: &Option>, + call_type: &str, + ) -> PyResult> { + py.import("litellm.utils")? + .getattr("async_post_call_success_deployment_hook")? + .call1((kwargs, response, call_type)) + .map(Bound::unbind) + } + + pub fn after_failure( + py: Python<'_>, + kwargs: &Py, + error: &Py, + call_type: &str, + ) -> PyResult> { + py.import("litellm.utils")? + .getattr("async_post_call_failure_deployment_hook")? + .call1((kwargs, error, call_type)) + .map(Bound::unbind) + } +} + +#[cfg(test)] +mod tests { + use pyo3::exceptions::PyTypeError; + + use super::*; + + #[test] + fn setup_fields_are_checked_lazily() { + Python::initialize(); + Python::attach(|py| { + let locals = PyDict::new(py); + py.run( + pyo3::ffi::c_str!( + r#" +reads = [] +class Logger: + def __getattribute__(self, name): + reads.append(name) + raise AssertionError('logger methods must remain lazy') +logger = Logger() +class Setup: + @property + def logger(self): + reads.append('logger') + return logger + @property + def bridge_owned(self): + reads.append('bridge_owned') + return True + @property + def kwargs(self): + reads.append('kwargs') + return [] +result = Setup() +"# + ), + Some(&locals), + Some(&locals), + ) + .unwrap(); + let result = SetupResult(locals.get_item("result").unwrap().unwrap()); + let logger = result.logger().unwrap(); + assert!( + logger + .object(py) + .is(locals.get_item("logger").unwrap().unwrap()) + ); + assert!(logger.bridge_owned()); + assert!( + result + .kwargs() + .unwrap_err() + .is_instance_of::(py) + ); + assert_eq!( + locals + .get_item("reads") + .unwrap() + .unwrap() + .extract::>() + .unwrap(), + ["logger", "bridge_owned", "kwargs"] + ); + }); + } + + #[test] + fn a_logger_extracted_from_a_bare_object_is_caller_owned() { + Python::initialize(); + Python::attach(|py| { + let logger: PythonLogger = py.None().into_bound(py).extract().unwrap(); + assert!(!logger.bridge_owned()); + }); + } +} diff --git a/litellm-rust/crates/python-bridge/src/lifecycle/preparation.rs b/litellm-rust/crates/callbacks-legacy/src/preparation.rs similarity index 95% rename from litellm-rust/crates/python-bridge/src/lifecycle/preparation.rs rename to litellm-rust/crates/callbacks-legacy/src/preparation.rs index e95f642e6ea..981b1702f2e 100644 --- a/litellm-rust/crates/python-bridge/src/lifecycle/preparation.rs +++ b/litellm-rust/crates/callbacks-legacy/src/preparation.rs @@ -1,6 +1,7 @@ -use litellm_auth::{credential_default_fields, credential_index}; -use pyo3::prelude::*; -use pyo3::types::{PyDict, PyList}; +use pyo3::{ + prelude::*, + types::{PyDict, PyList}, +}; struct CredentialEntry<'py>(Bound<'py, PyAny>); @@ -14,16 +15,16 @@ impl<'py> CredentialEntry<'py> { } } -pub(super) fn prepare<'py>( +pub fn prepare<'py>( py: Python<'py>, kwargs: &Bound<'py, PyDict>, - logger: &super::PythonLogger, + logger: &crate::PythonLogger, ) -> PyResult> { let arguments = kwargs.copy()?; arguments.set_item("litellm_logging_obj", logger.object(py))?; let litellm = py.import("litellm")?; inherit_credentials(py, &litellm, &arguments)?; - py.import("litellm.rust_bridge.lifecycle")? + py.import("litellm.rust_bridge.legacy_callbacks")? .getattr("check_limits")? .call1((&arguments,))?; Ok(arguments) @@ -49,7 +50,7 @@ fn inherit_credentials( .iter() .map(|credential| CredentialEntry(credential).name()) .collect::>>()?; - let Some(index) = credential_index(&requested, &names) else { + let Some(index) = names.iter().position(|name| *name == requested) else { py.import("litellm._logging")?.getattr("verbose_logger")?.call_method1( "warning", ("litellm_credential_name=%s matched none of the %d loaded credentials; the request runs without it", requested, names.len()), @@ -60,9 +61,9 @@ fn inherit_credentials( let values = selected.values()?; let supplied: Vec = arguments.keys().extract()?; let fields: Vec = values.keys().extract()?; - for name in credential_default_fields(&supplied, &fields) { - if let Some(value) = values.get_item(name)? { - arguments.set_item(name, value)?; + for name in fields.iter().filter(|name| !supplied.contains(name)) { + if let Some(value) = values.get_item(name.as_str())? { + arguments.set_item(name.as_str(), value)?; } } Ok(()) diff --git a/litellm-rust/crates/callbacks-legacy/tests/deferred.rs b/litellm-rust/crates/callbacks-legacy/tests/deferred.rs new file mode 100644 index 00000000000..3daea8840d8 --- /dev/null +++ b/litellm-rust/crates/callbacks-legacy/tests/deferred.rs @@ -0,0 +1,162 @@ +use std::ffi::CStr; + +use pyo3::prelude::*; +use pyo3::types::PyDict; +use rstest::rstest; + +use super::{PendingLogging, PendingSuccess}; +use crate::PythonLogger; +use crate::test_support::{local, namespace, run}; + +/// A deferred success for the namespace's `logger` and `response`, bound as `pending`. +fn defer<'py>(py: Python<'py>, script: &CStr) -> Bound<'py, PyDict> { + let locals = namespace(py, c"response = object()"); + run(py, &locals, script); + let pending = Py::new( + py, + PendingLogging { + pending: Some(PendingSuccess { + logger: PythonLogger::new(local(&locals, "logger").unbind(), true), + response: Some(local(&locals, "response").unbind()), + start: py.None(), + end: Some(py.None()), + }), + }, + ) + .unwrap(); + locals.set_item("pending", pending).unwrap(); + locals +} + +#[test] +fn release_enqueues_the_success_once_in_the_releasing_context() { + Python::initialize(); + Python::attach(|py| { + let locals = defer( + py, + c" +from contextvars import ContextVar + +marker = ContextVar('marker', default='unset') +observed = [] + +def on_enqueue(coroutine): + observed.append(marker.get()) + pending.release(True) + +logger.on_enqueue = on_enqueue +", + ); + run( + py, + &locals, + c" +marker.set('release') +pending.release(True) +pending.release(True) +assert observed == ['release'], observed +assert logger.names() == ['async_success_handler', 'enqueued'], logger.calls +assert logger.calls[0][1] is response +", + ); + }); +} + +#[test] +fn a_blocked_release_drops_the_success_for_good() { + Python::initialize(); + Python::attach(|py| { + let locals = defer(py, c""); + run( + py, + &locals, + c" +pending.release(False) +pending.release(True) +assert logger.calls == [], logger.calls +", + ); + }); +} + +#[test] +fn a_release_after_the_async_callbacks_went_away_only_keeps_the_books() { + Python::initialize(); + Python::attach(|py| { + let locals = defer(py, c"logger.needed = {'async_success': False}"); + run( + py, + &locals, + c" +pending.release(True) +assert logger.calls == [('success_bookkeeping', True)], logger.calls +", + ); + }); +} + +#[rstest] +#[case::ordinary_error(c"RuntimeError('queue full')", false)] +#[case::cancellation(c"asyncio.CancelledError()", true)] +fn a_failed_enqueue_closes_the_coroutine_and_is_never_replayed( + #[case] failure: &CStr, + #[case] propagates: bool, +) { + Python::initialize(); + Python::attach(|py| { + let locals = defer( + py, + c" +import asyncio + +def on_enqueue(coroutine): + raise failure + +logger.on_enqueue = on_enqueue +", + ); + locals + .set_item("failure", py.eval(failure, None, Some(&locals)).unwrap()) + .unwrap(); + let released = local(&locals, "pending").call_method1("release", (true,)); + match released { + Ok(_) => assert!(!propagates), + Err(error) => { + assert!(propagates); + assert!(error.value(py).is(local(&locals, "failure"))); + } + } + locals.set_item("propagates", propagates).unwrap(); + run( + py, + &locals, + c" +pending.release(True) +assert logger.names() == ['async_success_handler', 'enqueued', 'closed'], logger.calls +assert unraisable_from(logger) == ([] if propagates else [failure]) +", + ); + }); +} + +#[test] +fn an_unreleased_success_does_not_keep_its_logger_alive() { + Python::initialize(); + Python::attach(|py| { + let locals = defer(py, c""); + run( + py, + &locals, + c" +import gc +import weakref + +logger.pending = pending +reference = weakref.ref(logger) +del logger, pending +gc.collect() +assert reference() is None +", + ); + }); +} diff --git a/litellm-rust/crates/callbacks-legacy/tests/deployment_hooks.rs b/litellm-rust/crates/callbacks-legacy/tests/deployment_hooks.rs new file mode 100644 index 00000000000..3ceda4441a7 --- /dev/null +++ b/litellm-rust/crates/callbacks-legacy/tests/deployment_hooks.rs @@ -0,0 +1,246 @@ +use std::ffi::CStr; + +use litellm_callbacks::event::{CallEvent, FailureOrigin, Timing}; +use litellm_host_python::{AdapterStep, CallbackAdapter, PublicValue}; +use pyo3::exceptions::asyncio::CancelledError; +use pyo3::prelude::*; +use pyo3::types::PyDict; +use rstest::rstest; + +use super::LegacyLogging; +use crate::test_support::{legacy_call, local, namespace, run}; + +const CALL: &CStr = c" +document = {'type': 'document_url', 'document_url': 'data:application/pdf;base64,YWJj'} +kwargs = {'logger': logger, 'document': document} +"; + +const TIMING: Timing = Timing { + start_time: 0.0, + end_time: 1.0, +}; + +fn begin<'py>( + py: Python<'py>, + locals: &Bound<'py, PyDict>, + asynchronous: bool, +) -> (LegacyLogging, AdapterStep) { + let mut logging = legacy_call(py, locals, asynchronous); + let kwargs = local(locals, "kwargs") + .cast_into::() + .unwrap() + .unbind(); + let step = logging.begin(py, kwargs, 0.0).unwrap(); + (logging, step) +} + +fn arguments<'py>(py: Python<'py>, step: AdapterStep) -> Bound<'py, PyDict> { + let AdapterStep::Arguments(arguments) = step else { + panic!("expected the prepared arguments"); + }; + arguments.into_bound(py) +} + +fn awaits_deployment_hook(step: &AdapterStep) -> bool { + matches!(step, AdapterStep::Await(_)) +} + +#[rstest] +#[case::synchronous(false)] +#[case::asynchronous(true)] +fn deployment_pre_call_hook_runs_only_for_asynchronous_calls(#[case] asynchronous: bool) { + Python::initialize(); + Python::attach(|py| { + let locals = namespace(py, CALL); + let (_, step) = begin(py, &locals, asynchronous); + assert_eq!(awaits_deployment_hook(&step), asynchronous); + let names: Vec = local(&locals, "logger") + .call_method0("names") + .unwrap() + .extract() + .unwrap(); + assert_eq!(names.contains(&"pre_hook".to_string()), asynchronous); + }); +} + +#[test] +fn kwargs_returned_by_the_pre_call_hook_are_what_the_call_prepares() { + Python::initialize(); + Python::attach(|py| { + let locals = namespace( + py, + c" +document = {'type': 'document_url', 'document_url': 'data:application/pdf;base64,YWJj'} +replacement = {'type': 'document_url', 'document_url': 'data:application/pdf;base64,ZWRpdGVk'} +kwargs = {'logger': logger, 'document': document} +replaced_kwargs = {'logger': logger, 'document': replacement, 'pages': [0]} +", + ); + let (mut logging, step) = begin(py, &locals, true); + assert!(awaits_deployment_hook(&step)); + let step = logging + .resume(py, Ok(local(&locals, "replaced_kwargs").unbind())) + .unwrap(); + locals.set_item("prepared", arguments(py, step)).unwrap(); + run( + py, + &locals, + c" +assert prepared['document'] is replacement +assert prepared['pages'] is replaced_kwargs['pages'] +assert prepared['litellm_logging_obj'] is logger +assert 'litellm_logging_obj' not in replaced_kwargs +[checked] = [value for name, value in logger.calls if name == 'check_limits'] +assert checked is prepared +", + ); + }); +} + +#[test] +fn response_returned_by_the_post_call_hook_is_finalized_and_returned() { + Python::initialize(); + Python::attach(|py| { + let locals = namespace( + py, + c" +kwargs = {'logger': logger} +response = object() +replacement = object() +logger.hooks = {'pre': lambda kwargs: kwargs} +", + ); + let (mut logging, _) = begin(py, &locals, true); + logging + .resume(py, Ok(local(&locals, "kwargs").unbind())) + .unwrap(); + let step = logging + .after_success(py, local(&locals, "response").unbind(), TIMING) + .unwrap(); + assert!(awaits_deployment_hook(&step)); + let step = logging + .resume(py, Ok(local(&locals, "replacement").unbind())) + .unwrap(); + let AdapterStep::Response(returned) = step else { + panic!("expected the finalized response"); + }; + assert!(returned.bind(py).is(local(&locals, "replacement"))); + run( + py, + &locals, + c" +[finalized] = [value for name, value in logger.calls if name == 'finalize'] +assert finalized is replacement +", + ); + }); +} + +#[rstest] +#[case::pre_call(false)] +#[case::post_call(true)] +fn cancelling_a_deployment_hook_ends_the_call_with_that_cancellation(#[case] post_call: bool) { + Python::initialize(); + Python::attach(|py| { + let locals = namespace(py, c"kwargs = {'logger': logger}\nresponse = object()"); + let (mut logging, _) = begin(py, &locals, true); + if post_call { + logging + .resume(py, Ok(local(&locals, "kwargs").unbind())) + .unwrap(); + logging + .after_success(py, local(&locals, "response").unbind(), TIMING) + .unwrap(); + } + let cancellation = CancelledError::new_err("cancelled"); + let cancelled = cancellation.value(py).clone(); + let error = logging.resume(py, Err(cancellation)).err().unwrap(); + assert!(error.value(py).is(&cancelled)); + let names: Vec = local(&locals, "logger") + .call_method0("names") + .unwrap() + .extract() + .unwrap(); + assert!(!names.iter().any(|name| name.contains("handler"))); + }); +} + +#[rstest] +#[case::hook_completed(false)] +#[case::hook_cancelled(true)] +fn failure_callbacks_run_after_the_failure_hook_however_it_ends(#[case] cancelled: bool) { + Python::initialize(); + Python::attach(|py| { + let locals = namespace( + py, + c"kwargs = {'logger': logger}\nfailure = ValueError('provider')", + ); + let (mut logging, _) = begin(py, &locals, true); + logging + .resume(py, Ok(local(&locals, "kwargs").unbind())) + .unwrap(); + let failure = PyErr::from_value(local(&locals, "failure")); + let failed = CallEvent::Failed { + timing: TIMING, + origin: FailureOrigin::Call, + }; + let step = logging + .emit(py, &failed, Some(PublicValue::Error(&failure))) + .unwrap(); + assert!(awaits_deployment_hook(&step)); + let hook_result = if cancelled { + Err(CancelledError::new_err("cancelled")) + } else { + Ok(py.None()) + }; + assert!(matches!( + logging.resume(py, hook_result).unwrap(), + AdapterStep::Await(_) + )); + run( + py, + &locals, + c" +assert logger.names()[-3:] == ['failure_hook', 'failure_handler', 'async_failure_handler'], logger.calls +assert all(value is failure for name, value in logger.calls if name.endswith('_handler')) +", + ); + }); +} + +#[rstest] +#[case::synchronous(false)] +#[case::asynchronous(true)] +fn a_limit_rejected_before_the_call_surfaces_as_the_callers_error(#[case] asynchronous: bool) { + Python::initialize(); + Python::attach(|py| { + let locals = namespace( + py, + c" +class BudgetExceeded(Exception): + pass + +rejection = BudgetExceeded('over budget') + +class LimitedLogger(StubLogger): + def check_limits(self, arguments): + raise rejection + +logger = LimitedLogger() +logger.hooks = {'pre': lambda kwargs: kwargs} +kwargs = {'logger': logger} +", + ); + let mut logging = legacy_call(py, &locals, asynchronous); + let kwargs = local(&locals, "kwargs") + .cast_into::() + .unwrap() + .unbind(); + let result = logging.begin(py, kwargs, 0.0).and_then(|step| match step { + AdapterStep::Await(_) => logging.resume(py, Ok(local(&locals, "kwargs").unbind())), + step => Ok(step), + }); + let error = result.err().unwrap(); + assert!(error.value(py).is(local(&locals, "rejection"))); + }); +} diff --git a/litellm-rust/crates/callbacks-legacy/tests/payload.rs b/litellm-rust/crates/callbacks-legacy/tests/payload.rs new file mode 100644 index 00000000000..480bedf8548 --- /dev/null +++ b/litellm-rust/crates/callbacks-legacy/tests/payload.rs @@ -0,0 +1,365 @@ +use std::ffi::CStr; + +use litellm_callbacks::event::{CallEvent, Passthrough, RawResponse, RequestContext, WireRequest}; +use litellm_host_python::{AdapterStep, CallbackAdapter}; +use pyo3::prelude::*; +use rstest::rstest; +use serde_json::{Value, json}; + +use super::LegacyLogging; +use crate::PythonLogger; +use crate::test_support::{legacy_call, local, namespace, run}; + +/// The payload phases of `Logging` on top of `StubLogger`, with `pre_call` handing the +/// payload to the case's `on_pre_call`. +const PAYLOAD_LOGGER: &CStr = c" +class Request: + pass + +class PayloadLogger(StubLogger): + def update_from_kwargs(self, **update): + self.update = update + + def pre_call(self, input, api_key, additional_args): + self.record('pre_call', None) + self.pre = additional_args + on_pre_call(additional_args) + + def _pre_call(self, input, api_key, additional_args): + self.record('_pre_call', None) + + def record_api_call_start_time(self): + self.record('record_api_call_start_time', None) + + def post_call(self, original_response, additional_args): + self.record('post_call', None) + self.post = (original_response, additional_args) + + def record_post_call(self, response, *rest): + self.record('record_post_call', response) + +request = Request() +kwargs = {} +logger = PayloadLogger() +on_pre_call = lambda additional_args: None +check = lambda: None +"; + +const DOCUMENT: &str = "data:application/pdf;base64,YWJj"; +const EDITED: &str = "data:application/pdf;base64,ZWRpdGVk"; + +fn document(source: &str) -> Value { + json!({"type": "document_url", "document_url": source}) +} + +fn before_send(script: &CStr, caller: Value, body: Value) -> WireRequest { + before_send_with_secrets(script, caller, body, &[]) +} + +/// Runs `before_send` over `body` for a caller whose route-side view is `caller`, with the +/// Python objects `script` binds, then delivers the provider's raw response the way the +/// driver does and runs the script's `check()`. +fn before_send_with_secrets( + script: &CStr, + caller: Value, + body: Value, + secret_fields: &[&str], +) -> WireRequest { + Python::initialize(); + Python::attach(|py| { + let locals = namespace(py, PAYLOAD_LOGGER); + run(py, &locals, script); + let mut logging = LegacyLogging { + logger: Some(PythonLogger::new(local(&locals, "logger").unbind(), true)), + ..legacy_call(py, &locals, false) + }; + let context = RequestContext { + model: "model".into(), + custom_llm_provider: "provider".into(), + optional_params: caller.clone(), + passthrough_fields: Passthrough::unchanged(caller.as_object().unwrap(), &body), + secret_fields: secret_fields.iter().map(|name| name.to_string()).collect(), + }; + let wire = WireRequest { + url: "https://provider.invalid/ocr".into(), + headers: vec![("x-route".into(), "route".into())], + body, + }; + let step = logging.before_send(py, Box::new(wire), &context).unwrap(); + let raw = CallEvent::ResponseReceived { + raw: RawResponse { + body: "raw response".into(), + }, + }; + assert!(matches!( + logging.emit(py, &raw, None).unwrap(), + AdapterStep::Done + )); + run(py, &locals, c"check()"); + let AdapterStep::Wire(wire) = step else { + panic!("before_send did not hand back the wire request"); + }; + *wire + }) +} + +#[rstest] +#[case::caller_keyword(c" +document = {'type': 'document_url', 'document_url': 'data:application/pdf;base64,YWJj'} +pages = [0] +kwargs = {'document': document, 'pages': pages} +observed = [] +on_pre_call = lambda args: observed.append( + (args['complete_input_dict']['document'] is document, args['complete_input_dict']['pages'] is pages) +) +def check(): + assert observed == [(True, True)], observed +")] +#[case::request_attribute_behind_an_omitted_keyword(c" +document = {'type': 'document_url', 'document_url': 'data:application/pdf;base64,YWJj'} +pages = [0] +request.document = document +kwargs = {'pages': pages} +observed = [] +on_pre_call = lambda args: observed.append( + (args['complete_input_dict']['document'] is document, args['complete_input_dict']['pages'] is pages) +) +def check(): + assert observed == [(True, True)], observed +")] +fn passthrough_keys_reach_pre_call_as_the_callers_own_objects(#[case] script: &CStr) { + let body = json!({"model": "model", "document": document(DOCUMENT), "pages": [0]}); + let wire = before_send( + script, + json!({"document": document(DOCUMENT), "pages": [0]}), + body.clone(), + ); + assert_eq!(wire.body, body); +} + +#[test] +fn pre_call_edit_of_a_passthrough_object_reaches_the_caller_and_the_wire() { + let wire = before_send( + c" +document = {'type': 'document_url', 'document_url': 'data:application/pdf;base64,YWJj'} +kwargs = {'document': document} +def on_pre_call(args): + args['complete_input_dict']['document']['document_url'] = 'data:application/pdf;base64,ZWRpdGVk' +def check(): + assert document['document_url'] == 'data:application/pdf;base64,ZWRpdGVk' +", + json!({"document": document(DOCUMENT)}), + json!({"document": document(DOCUMENT)}), + ); + assert_eq!(wire.body["document"], document(EDITED)); +} + +#[test] +fn a_body_key_the_route_rewrote_is_not_the_callers_object() { + let wire = before_send( + c" +document = {'type': 'document_url', 'document_url': 'https://example.invalid/scan.pdf'} +kwargs = {'document': document} +observed = [] +def on_pre_call(args): + observed.append(args['complete_input_dict']['document'] is document) + args['complete_input_dict']['document']['document_name'] = 'edited.pdf' +def check(): + assert observed == [False], observed + assert document == {'type': 'document_url', 'document_url': 'https://example.invalid/scan.pdf'} +", + json!({"document": document("https://example.invalid/scan.pdf")}), + json!({"document": document(DOCUMENT)}), + ); + assert_eq!( + wire.body["document"], + json!({"type": "document_url", "document_url": DOCUMENT, "document_name": "edited.pdf"}) + ); +} + +#[rstest] +#[case::body( + c" +def on_pre_call(args): + args['complete_input_dict'] = {'replacement': True} +" +)] +#[case::headers( + c" +def on_pre_call(args): + args['headers'] = {'x-replacement': 'yes'} +" +)] +fn rebinding_the_payload_envelope_does_not_reach_the_wire(#[case] script: &CStr) { + let body = json!({"document": document(DOCUMENT)}); + let wire = before_send(script, json!({}), body.clone()); + assert_eq!(wire.body, body); + assert_eq!(wire.headers, [("x-route".to_string(), "route".to_string())]); +} + +#[test] +fn pre_call_header_edit_reaches_the_wire() { + let wire = before_send( + c" +def on_pre_call(args): + args['headers']['x-callback'] = 'edited' +", + json!({}), + json!({}), + ); + assert_eq!( + wire.headers, + [ + ("x-route".to_string(), "route".to_string()), + ("x-callback".to_string(), "edited".to_string()), + ] + ); +} + +#[test] +fn pre_call_receives_the_wire_request_and_the_logger_its_redacted_request() { + let body = json!({"model": "model", "document": document(DOCUMENT)}); + before_send_with_secrets( + c" +logger_fn = lambda *args: None +kwargs = { + 'litellm_call_id': 'call-1', + 'client_secret': 'shh', + 'proxy_server_request': {'body': {}}, + 'logger_fn': logger_fn, + 'litellm_request_debug': True, + 'ocr_cost_per_page': 0.05, +} +observed = [] +on_pre_call = observed.append +def check(): + [args] = observed + assert args['api_base'] == 'https://provider.invalid/ocr', args + assert args['complete_input_dict'] == { + 'model': 'model', + 'document': {'type': 'document_url', 'document_url': 'data:application/pdf;base64,YWJj'}, + }, args + update = logger.update + assert update['model'] == 'model' and update['custom_llm_provider'] == 'provider', update + assert update['litellm_params']['litellm_call_id'] == 'call-1', update + assert update['litellm_params']['api_base'] == 'https://provider.invalid/ocr', update + assert update['litellm_params']['logger_fn'] is logger_fn, update + assert update['litellm_params']['litellm_request_debug'] is True, update + assert update['litellm_params']['ocr_cost_per_page'] == 0.05, update + assert update['kwargs']['client_secret'] == '****', update + assert 'proxy_server_request' not in update['kwargs'], update + assert update['optional_params']['client_secret'] == '****', update +", + json!({"client_secret": "shh"}), + body, + &["client_secret"], + ); +} + +#[rstest] +#[case::added_key( + c" +def on_pre_call(args): + args['complete_input_dict']['include_image_base64'] = True +", + json!({"document": document(DOCUMENT), "include_image_base64": true}) +)] +#[case::replaced_document( + c" +document = {'type': 'document_url', 'document_url': 'data:application/pdf;base64,YWJj'} +kwargs = {'document': document} +def on_pre_call(args): + args['complete_input_dict']['document'] = { + 'type': 'document_url', 'document_url': 'data:application/pdf;base64,ZWRpdGVk' + } +def check(): + assert document['document_url'] == 'data:application/pdf;base64,YWJj', document +", + json!({"document": document(EDITED)}) +)] +#[case::retained_body_edited_after_rebinding( + c" +def on_pre_call(args): + retained = args['complete_input_dict'] + args['complete_input_dict'] = {'rebound': True} + retained['include_image_base64'] = True +", + json!({"document": document(DOCUMENT), "include_image_base64": true}) +)] +fn pre_call_body_edits_reach_the_wire(#[case] script: &CStr, #[case] expected: Value) { + let body = json!({"document": document(DOCUMENT)}); + let wire = before_send(script, json!({"document": document(DOCUMENT)}), body); + assert_eq!(wire.body, expected); +} + +#[test] +fn retained_headers_edited_after_rebinding_reach_the_wire() { + let wire = before_send( + c" +def on_pre_call(args): + retained = args['headers'] + args['headers'] = {'x-rebound': 'rebound'} + retained['x-retained'] = 'sent' +", + json!({}), + json!({}), + ); + assert_eq!( + wire.headers, + [ + ("x-route".to_string(), "route".to_string()), + ("x-retained".to_string(), "sent".to_string()), + ] + ); +} + +#[test] +fn post_call_receives_the_raw_response_and_the_payload_dicts_pre_call_saw() { + before_send( + c" +def check(): + original_response, additional_args = logger.post + assert original_response == 'raw response', original_response + assert additional_args['complete_input_dict'] is logger.pre['complete_input_dict'] + assert additional_args['headers'] is logger.pre['headers'] +", + json!({}), + json!({"document": document(DOCUMENT)}), + ); +} + +#[rstest] +#[case::every_phase_listens(c"{}", &["pre_call", "post_call"])] +#[case::no_input_callback( + c"{'input': False}", + &["_pre_call", "record_api_call_start_time", "record_post_call"] +)] +#[case::no_payload_consumer(c"{'payload': False}", &["record_api_call_start_time"])] +fn payload_callbacks_run_only_for_the_phases_someone_listens_to( + #[case] needed: &CStr, + #[case] expected_calls: &[&str], +) { + let script = std::ffi::CString::new(format!( + " +logger.needed = {needed} +def on_pre_call(args): + args['complete_input_dict']['include_image_base64'] = True +def check(): + assert logger.names() == {expected_calls:?}, logger.calls +", + needed = needed.to_str().unwrap(), + expected_calls = expected_calls, + )) + .unwrap(); + let body = json!({"document": document(DOCUMENT)}); + let wire = before_send(&script, json!({}), body.clone()); + let edited = json!({"document": document(DOCUMENT), "include_image_base64": true}); + assert_eq!( + wire.body, + if expected_calls.contains(&"pre_call") { + edited + } else { + body + } + ); +} diff --git a/litellm-rust/crates/callbacks-legacy/tests/support.rs b/litellm-rust/crates/callbacks-legacy/tests/support.rs new file mode 100644 index 00000000000..1663e11963e --- /dev/null +++ b/litellm-rust/crates/callbacks-legacy/tests/support.rs @@ -0,0 +1,188 @@ +use std::ffi::CStr; + +use pyo3::prelude::*; +use pyo3::types::{PyDict, PyTuple}; + +use crate::{LegacyLogging, LegacySurface, PublicCall}; + +/// Stand-ins for every litellm function the legacy contract calls. Tests share one +/// interpreter and run concurrently, so each stub is installed idempotently and forwards to +/// the per-test `StubLogger` it is handed (directly, or as `kwargs['logger']`). +const STUBS: &CStr = c" +import contextvars +import sys +import types + +for name in ( + 'litellm', + 'litellm.utils', + 'litellm.types', + 'litellm.types.utils', + 'litellm._internal_context', + 'litellm.litellm_core_utils', + 'litellm.litellm_core_utils.logging_worker', + 'litellm.litellm_core_utils.litellm_logging', + 'litellm.rust_bridge', + 'litellm.rust_bridge.legacy_callbacks', +): + sys.modules.setdefault(name, types.ModuleType(name)) + +legacy = sys.modules['litellm.rust_bridge.legacy_callbacks'] +legacy.setup = lambda call_type, args, kwargs, start, asynchronous: types.SimpleNamespace( + logger=kwargs['logger_factory'](kwargs) if 'logger_factory' in kwargs else kwargs['logger'], + kwargs=kwargs, + bridge_owned=True, +) +legacy.deployment_callbacks_needed = lambda: True +legacy.check_limits = lambda arguments: arguments['logger'].check_limits(arguments) +legacy.callbacks_needed = lambda logger, phase: logger.needed.get(phase, True) +legacy.success_bookkeeping = lambda logger, response, start, end, asynchronous: logger.record( + 'success_bookkeeping', asynchronous +) +legacy.failure_bookkeeping = lambda logger, error, start, end, asynchronous: logger.record( + 'failure_bookkeeping', asynchronous +) +legacy.finalize = lambda response, logger, kwargs, start, end: logger.record('finalize', response) + +utils = sys.modules['litellm.utils'] +utils.async_pre_call_deployment_hook = lambda kwargs, call_type: kwargs['logger'].hook( + 'pre', kwargs, call_type +) +utils.async_post_call_success_deployment_hook = lambda kwargs, response, call_type: kwargs[ + 'logger' +].hook('success', response, call_type) +utils.async_post_call_failure_deployment_hook = lambda kwargs, error, call_type: kwargs[ + 'logger' +].hook('failure', error, call_type) +utils._restore_correlation_context_if_supported = lambda logger: logger.record('restore', None) + +internal = sys.modules['litellm._internal_context'] +if not hasattr(internal, 'is_internal_call'): + internal.is_internal_call = contextvars.ContextVar('is_internal_call', default=False) + +sys.modules['litellm.types.utils'].CustomPricingLiteLLMParams = type( + 'CustomPricingLiteLLMParams', (), {'model_fields': {'ocr_cost_per_page': None}} +) + + +unraisable = sys.modules.setdefault( + 'litellm_test_unraisable', types.ModuleType('litellm_test_unraisable') +) +if not hasattr(unraisable, 'events'): + unraisable.events = [] + sys.unraisablehook = lambda event: unraisable.events.append((event.object, event.exc_value)) + + +def unraisable_from(owner): + return [error for source, error in unraisable.events if source is owner] + + +class Worker: + def ensure_initialized_and_enqueue(self, coroutine): + return coroutine.enqueue() + + +class Executor: + def submit(self, run, handler, *args): + handler.__self__.record('submit', args) + + +sys.modules['litellm.litellm_core_utils.logging_worker'].GLOBAL_LOGGING_WORKER = Worker() +sys.modules['litellm.litellm_core_utils.litellm_logging'].executor = Executor() + + +class StubCoroutine: + def __init__(self, logger): + self.logger = logger + + def enqueue(self): + self.logger.record('enqueued', None) + self.logger.on_enqueue(self) + + def close(self): + self.logger.record('closed', None) + + +class StubLogger: + def __init__(self): + self.calls = [] + self.needed = {} + self.hooks = {} + self.on_enqueue = lambda coroutine: None + + def record(self, name, value): + self.calls.append((name, value)) + + def names(self): + return [name for name, _ in self.calls] + + def hook(self, phase, value, call_type): + self.record(phase + '_hook', call_type) + return self.hooks.get(phase, lambda value: 'awaitable')(value) + + def check_limits(self, arguments): + self.record('check_limits', arguments) + + def failure_handler(self, error, trace, start, end): + self.record('failure_handler', error) + + def async_failure_handler(self, error, trace, start, end): + self.record('async_failure_handler', error) + return 'awaitable' + + def success_handler(self, response, start, end): + self.record('success_handler', response) + + def async_success_handler(self, response, start, end): + self.record('async_success_handler', response) + return StubCoroutine(self) + + def handle_sync_success_callbacks_for_async_calls(self, response, start, end): + self.record('sync_success_for_async_call', response) + + +logger = StubLogger() +"; + +/// A namespace with the stubs, `StubLogger` and a fresh `logger`, after `script` ran in it. +pub(crate) fn namespace<'py>(py: Python<'py>, script: &CStr) -> Bound<'py, PyDict> { + let locals = PyDict::new(py); + py.run(STUBS, Some(&locals), Some(&locals)).unwrap(); + py.run(script, Some(&locals), Some(&locals)).unwrap(); + locals +} + +pub(crate) fn run(py: Python<'_>, locals: &Bound<'_, PyDict>, code: &CStr) { + py.run(code, Some(locals), Some(locals)).unwrap(); +} + +pub(crate) fn local<'py>(locals: &Bound<'py, PyDict>, name: &str) -> Bound<'py, PyAny> { + locals.get_item(name).unwrap().unwrap() +} + +/// A legacy call over the namespace's `kwargs` (or none) and `request` (or `None`). +pub(crate) fn legacy_call( + py: Python<'_>, + locals: &Bound<'_, PyDict>, + asynchronous: bool, +) -> LegacyLogging { + let request = locals + .get_item("request") + .unwrap() + .unwrap_or_else(|| py.None().into_bound(py)); + let kwargs = locals + .get_item("kwargs") + .unwrap() + .map(|kwargs| kwargs.cast_into::().unwrap()) + .unwrap_or_else(|| PyDict::new(py)); + let call = PublicCall::capture(&request, &PyTuple::empty(py), &kwargs).unwrap(); + LegacyLogging::new( + py, + LegacySurface { + call_type: "test", + input_description: "test input", + }, + call, + asynchronous, + ) +} diff --git a/litellm-rust/crates/callbacks-legacy/tests/terminal.rs b/litellm-rust/crates/callbacks-legacy/tests/terminal.rs new file mode 100644 index 00000000000..9b9d29108f6 --- /dev/null +++ b/litellm-rust/crates/callbacks-legacy/tests/terminal.rs @@ -0,0 +1,291 @@ +use std::ffi::CStr; + +use litellm_callbacks::event::{CallEvent, FailureOrigin, Timing}; +use litellm_host_python::{AdapterStep, CallbackAdapter, PublicValue}; +use pyo3::exceptions::PyRuntimeError; +use pyo3::exceptions::asyncio::CancelledError; +use pyo3::prelude::*; +use pyo3::types::PyDict; +use rstest::rstest; + +use super::LegacyLogging; +use crate::PythonLogger; +use crate::test_support::{legacy_call, local, namespace, run}; + +const TIMING: Timing = Timing { + start_time: 0.0, + end_time: 1.0, +}; + +fn logged(py: Python<'_>, locals: &Bound<'_, PyDict>, asynchronous: bool) -> LegacyLogging { + LegacyLogging { + logger: Some(PythonLogger::new(local(locals, "logger").unbind(), true)), + ..legacy_call(py, locals, asynchronous) + } +} + +fn succeed(py: Python<'_>, locals: &Bound<'_, PyDict>, logging: &mut LegacyLogging) -> AdapterStep { + let response = local(locals, "response").unbind(); + logging + .emit( + py, + &CallEvent::Succeeded { timing: TIMING }, + Some(PublicValue::Response(&response)), + ) + .unwrap() +} + +fn fail(py: Python<'_>, locals: &Bound<'_, PyDict>, logging: &mut LegacyLogging) -> AdapterStep { + let failure = PyErr::from_value(local(locals, "failure")); + logging + .emit( + py, + &CallEvent::Failed { + timing: TIMING, + origin: FailureOrigin::Host, + }, + Some(PublicValue::Error(&failure)), + ) + .unwrap() +} + +#[rstest] +#[case::sync_listened(false, c"", &["submit"])] +#[case::sync_unlistened(false, c"logger.needed = {'sync_success': False}", &["success_bookkeeping"])] +#[case::async_listened( + true, + c"", + &["async_success_handler", "enqueued", "sync_success_for_async_call"] +)] +#[case::async_unlistened( + true, + c"logger.needed = {'async_success': False, 'sync_success_async': False}", + &["success_bookkeeping"] +)] +#[case::async_deferred(true, c"logger._defer_async_logging = True", &["sync_success_for_async_call"])] +#[case::async_with_fallbacks(true, c"kwargs = {'fallbacks': ['other']}", &["sync_success_for_async_call"])] +fn success_reaches_only_the_callbacks_that_listen( + #[case] asynchronous: bool, + #[case] script: &CStr, + #[case] expected: &[&str], +) { + Python::initialize(); + Python::attach(|py| { + let locals = namespace(py, c"response = object()"); + run(py, &locals, script); + let mut logging = logged(py, &locals, asynchronous); + assert!(matches!( + succeed(py, &locals, &mut logging), + AdapterStep::Done + )); + let names: Vec = local(&locals, "logger") + .call_method0("names") + .unwrap() + .extract() + .unwrap(); + assert_eq!(names, expected); + run( + py, + &locals, + c" +assert all(value is response for name, value in logger.calls if name.endswith('_handler')) +assert hasattr(logger, '_native_pending_logging') == getattr(logger, '_defer_async_logging', False) +", + ); + }); +} + +#[rstest] +#[case::synchronous(false, &["failure_handler"])] +#[case::asynchronous(true, &[])] +fn internal_calls_skip_failure_callbacks_only_when_asynchronous( + #[case] asynchronous: bool, + #[case] expected: &[&str], +) { + Python::initialize(); + Python::attach(|py| { + let locals = namespace(py, c"failure = ValueError('provider')"); + let mut logging = LegacyLogging { + internal: true, + ..logged(py, &locals, asynchronous) + }; + assert!(matches!(fail(py, &locals, &mut logging), AdapterStep::Done)); + let names: Vec = local(&locals, "logger") + .call_method0("names") + .unwrap() + .extract() + .unwrap(); + assert_eq!(names, expected); + }); +} + +#[test] +fn internal_async_calls_skip_the_async_success_fan_out() { + Python::initialize(); + Python::attach(|py| { + let locals = namespace(py, c"response = object()"); + let mut logging = LegacyLogging { + internal: true, + ..logged(py, &locals, true) + }; + succeed(py, &locals, &mut logging); + run( + py, + &locals, + c"assert logger.names() == ['sync_success_for_async_call'], logger.calls", + ); + }); +} + +#[test] +fn a_failing_success_callback_is_reported_without_replacing_the_response() { + Python::initialize(); + Python::attach(|py| { + let locals = namespace( + py, + c" +response = object() +failure = ValueError('terminal diagnostic') + +class FailingLogger(StubLogger): + def handle_sync_success_callbacks_for_async_calls(self, *args): + raise failure + +logger = FailingLogger() +", + ); + let mut logging = logged(py, &locals, true); + assert!(matches!( + succeed(py, &locals, &mut logging), + AdapterStep::Done + )); + assert!( + logging + .response + .as_ref() + .unwrap() + .bind(py) + .is(local(&locals, "response")) + ); + run(py, &locals, c"assert unraisable_from(logger) == [failure]"); + }); +} + +#[rstest] +#[case::sync_listened(false, c"", &["failure_handler"])] +#[case::sync_unlistened(false, c"logger.needed = {'sync_failure': False}", &["failure_bookkeeping"])] +#[case::async_listened(true, c"", &["failure_handler", "async_failure_handler"])] +#[case::async_unlistened( + true, + c"logger.needed = {'sync_failure': False, 'async_failure': False}", + &["failure_bookkeeping", "failure_bookkeeping"] +)] +fn failure_reaches_only_the_callbacks_that_listen( + #[case] asynchronous: bool, + #[case] script: &CStr, + #[case] expected: &[&str], +) { + Python::initialize(); + Python::attach(|py| { + let locals = namespace(py, c"failure = ValueError('provider')"); + run(py, &locals, script); + let mut logging = logged(py, &locals, asynchronous); + let step = fail(py, &locals, &mut logging); + let awaits_async_handler = expected.contains(&"async_failure_handler"); + assert_eq!(matches!(step, AdapterStep::Await(_)), awaits_async_handler); + let names: Vec = local(&locals, "logger") + .call_method0("names") + .unwrap() + .extract() + .unwrap(); + assert_eq!(names, expected); + run( + py, + &locals, + c"assert all(value is failure for name, value in logger.calls if name.endswith('_handler'))", + ); + }); +} + +#[test] +fn a_failing_sync_failure_callback_keeps_the_error_and_still_runs_the_async_family() { + Python::initialize(); + Python::attach(|py| { + let locals = namespace( + py, + c" +failure = ValueError('selected') + +class FailingLogger(StubLogger): + def failure_handler(self, error, trace, start, end): + self.record('failure_handler', error) + raise RuntimeError('handler failed') + +logger = FailingLogger() +", + ); + let mut logging = logged(py, &locals, true); + assert!(matches!( + fail(py, &locals, &mut logging), + AdapterStep::Await(_) + )); + assert!( + logging + .error + .as_ref() + .unwrap() + .bind(py) + .is(local(&locals, "failure")) + ); + run( + py, + &locals, + c"assert logger.names() == ['failure_handler', 'async_failure_handler'], logger.calls", + ); + }); +} + +#[rstest] +#[case::completed(None, true)] +#[case::handler_error(Some(false), true)] +#[case::cancelled(Some(true), false)] +fn the_async_failure_handler_ends_the_call_unless_it_was_cancelled( + #[case] error: Option, + #[case] done: bool, +) { + Python::initialize(); + Python::attach(|py| { + let locals = namespace(py, c"failure = ValueError('provider')"); + let mut logging = logged(py, &locals, true); + fail(py, &locals, &mut logging); + let result = match error { + None => Ok(py.None()), + Some(false) => Err(PyRuntimeError::new_err("handler failed")), + Some(true) => Err(CancelledError::new_err("cancelled")), + }; + let expected = result.as_ref().err().map(|error| error.value(py).clone()); + match logging.resume(py, result) { + Ok(step) => assert!(done && matches!(step, AdapterStep::Done)), + Err(propagated) => { + assert!(!done); + assert!(propagated.value(py).is(expected.unwrap())); + } + } + }); +} + +#[test] +fn closing_restores_the_correlation_context_once() { + Python::initialize(); + Python::attach(|py| { + let locals = namespace(py, c""); + let mut logging = logged(py, &locals, true); + logging.close(py); + logging.close(py); + run( + py, + &locals, + c"assert logger.names() == ['restore'], logger.calls", + ); + }); +} diff --git a/litellm-rust/crates/python-interop/Cargo.toml b/litellm-rust/crates/callbacks/Cargo.toml similarity index 65% rename from litellm-rust/crates/python-interop/Cargo.toml rename to litellm-rust/crates/callbacks/Cargo.toml index 9da6af6e2e2..4b966271478 100644 --- a/litellm-rust/crates/python-interop/Cargo.toml +++ b/litellm-rust/crates/callbacks/Cargo.toml @@ -1,15 +1,13 @@ [package] -name = "litellm-python-interop" +name = "litellm-callbacks" version = "0.1.0" edition.workspace = true license.workspace = true repository.workspace = true [dependencies] -pyo3.workspace = true -pythonize.workspace = true -serde.workspace = true +serde_json.workspace = true [dev-dependencies] rstest.workspace = true -serde_json.workspace = true +tokio = { workspace = true, features = ["macros"] } diff --git a/litellm-rust/crates/callbacks/src/event.rs b/litellm-rust/crates/callbacks/src/event.rs new file mode 100644 index 00000000000..e6f88fd9709 --- /dev/null +++ b/litellm-rust/crates/callbacks/src/event.rs @@ -0,0 +1,135 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde_json::{Map, Value}; + +/// Seconds since the Unix epoch, on one clock for every host. +pub fn epoch_seconds() -> f64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs_f64()) + .unwrap_or(0.0) +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Timing { + pub start_time: f64, + pub end_time: f64, +} + +/// The provider request as it is about to leave, offered to the host for rewriting. +#[derive(Clone, Debug, PartialEq)] +pub struct WireRequest { + pub url: String, + pub headers: Vec<(String, String)>, + pub body: Value, +} + +/// What the route knows about the request it is sending, for a host that logs it. The +/// route owns these facts; a host reads them beside the wire request and never rewrites +/// them. +#[derive(Clone, Debug, PartialEq)] +pub struct RequestContext { + pub model: String, + pub custom_llm_provider: String, + /// The route's parameters before the provider transformation. + pub optional_params: Value, + pub passthrough_fields: Passthrough, + /// Optional-param names that carry credentials and must be redacted when logged. + pub secret_fields: Vec, +} + +/// Body keys whose values are the caller's inputs, unchanged by the route. The only way to +/// build one is to compare the two, so a route cannot name a key it rewrote. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Passthrough(Vec); + +impl Passthrough { + pub fn unchanged(caller: &Map, body: &Value) -> Self { + Self( + caller + .iter() + .filter(|(name, value)| body.get(name.as_str()) == Some(*value)) + .map(|(name, _)| name.clone()) + .collect(), + ) + } + + pub fn iter(&self) -> impl Iterator { + self.0.iter().map(String::as_str) + } + + pub fn contains(&self, name: &str) -> bool { + self.0.iter().any(|field| field == name) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RawResponse { + pub body: String, +} + +/// Whether a failure surfaced inside the call, including a host op the call asked for, +/// or in a host step around it (preparing the arguments, finalizing the response). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FailureOrigin { + Call, + Host, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum CallEvent { + ResponseReceived { + raw: RawResponse, + }, + Succeeded { + timing: Timing, + }, + Failed { + timing: Timing, + origin: FailureOrigin, + }, +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + use serde_json::json; + + use super::*; + + #[rstest] + #[case::unchanged_scalar(json!({"pages": [0]}), json!({"pages": [0]}), &["pages"])] + #[case::unchanged_explicit_null(json!({"pages": null}), json!({"pages": null}), &["pages"])] + #[case::unchanged_nested_object( + json!({"document": {"type": "document_url", "document_url": "https://a/b.pdf"}}), + json!({"document": {"type": "document_url", "document_url": "https://a/b.pdf"}, "model": "m"}), + &["document"] + )] + #[case::rewritten_value( + json!({"document": {"type": "document_url", "document_url": "https://a/b.pdf"}}), + json!({"document": {"type": "document_url", "document_url": "data:application/pdf;base64,YWJj"}}), + &[] + )] + #[case::dropped_nested_field( + json!({"document": {"type": "image_url", "image_url": "https://a/b.png", "document_name": "b.png"}}), + json!({"document": {"type": "image_url", "image_url": "https://a/b.png"}}), + &[] + )] + #[case::added_nested_field( + json!({"document": {"type": "image_url", "image_url": "https://a/b.png"}}), + json!({"document": {"type": "image_url", "image_url": "https://a/b.png", "detail": "high"}}), + &[] + )] + #[case::reordered_array(json!({"pages": [0, 1]}), json!({"pages": [1, 0]}), &[])] + #[case::consumed_by_the_route(json!({"api_key": "k", "pages": [0]}), json!({"pages": [0]}), &["pages"])] + #[case::added_by_the_route(json!({}), json!({"model": "m"}), &[])] + #[case::non_object_body(json!({"pages": [0]}), json!([{"pages": [0]}]), &[])] + fn passthrough_is_exactly_the_callers_unchanged_keys( + #[case] caller: Value, + #[case] body: Value, + #[case] expected: &[&str], + ) { + let passthrough = Passthrough::unchanged(caller.as_object().unwrap(), &body); + assert_eq!(passthrough.iter().collect::>(), expected); + } +} diff --git a/litellm-rust/crates/callbacks/src/host.rs b/litellm-rust/crates/callbacks/src/host.rs new file mode 100644 index 00000000000..2392718a18d --- /dev/null +++ b/litellm-rust/crates/callbacks/src/host.rs @@ -0,0 +1,45 @@ +use std::future::Future; + +use crate::event::{CallEvent, RequestContext, WireRequest}; +use crate::route::Route; + +/// One suspension point of a native call, performed by the host. +pub enum HostOp { + Route(R::Op), + BeforeSend { + wire: Box, + context: Box, + }, + Emit(CallEvent), +} + +pub enum HostResult { + Route(R::OpResult), + BeforeSend(Box), + Emitted, +} + +/// A host answer that is either available now or arrives once the host's own +/// suspension (a Python awaitable, for example) resolves. +pub enum HostStep { + Ready(V), + Suspend(S), +} + +/// An in-process host: answers route operations and observes the call without leaving +/// the Rust runtime. Language hosts implement their own driver instead. +pub trait Host: Send + Sync { + fn route(&self, op: R::Op) -> impl Future> + Send; + + fn before_send( + &self, + wire: WireRequest, + _context: &RequestContext, + ) -> impl Future> + Send { + async move { Ok(wire) } + } + + fn emit(&self, _event: &CallEvent) -> impl Future> + Send { + async { Ok(()) } + } +} diff --git a/litellm-rust/crates/callbacks/src/lib.rs b/litellm-rust/crates/callbacks/src/lib.rs new file mode 100644 index 00000000000..41b0983f0ce --- /dev/null +++ b/litellm-rust/crates/callbacks/src/lib.rs @@ -0,0 +1,12 @@ +//! The contract between a native call and the host runtime that drives it. +//! +//! A host is whatever sits on the far side of the language boundary: CPython today, +//! another runtime later. Core implements [`machine::Machine`] per route and never learns +//! which host is on the other end. The machine yields [`host::HostOp`]s; a driver answers +//! them, observes [`event::CallEvent`]s and may rewrite the wire request before it is sent. + +pub mod event; +pub mod host; +pub mod machine; +pub mod route; +pub mod run; diff --git a/litellm-rust/crates/callbacks/src/machine.rs b/litellm-rust/crates/callbacks/src/machine.rs new file mode 100644 index 00000000000..2942913f095 --- /dev/null +++ b/litellm-rust/crates/callbacks/src/machine.rs @@ -0,0 +1,63 @@ +use std::future::Future; +use std::pin::Pin; + +use crate::host::{HostOp, HostResult}; +use crate::route::Route; + +pub enum MachineStep { + Host(HostOp), + Complete(C), +} + +pub type Step<'a, M> = Pin< + Box< + dyn Future< + Output = Result< + MachineStep<::Route, ::Complete>, + <::Route as Route>::Error, + >, + > + Send + + 'a, + >, +>; + +pub type Interrupted<'a, M> = Pin< + Box< + dyn Future< + Output = Result<::Complete, <::Route as Route>::Error>, + > + Send + + 'a, + >, +>; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum HostFailure { + Error(E), + Cancelled(E), +} + +impl HostFailure { + pub fn into_error(self) -> E { + match self { + Self::Error(error) | Self::Cancelled(error) => error, + } + } +} + +/// A resumable call. Core implements it per route; a host drives it. Every suspension +/// point is an op the host performs and answers with a result. +pub trait Machine: Send { + type Route: Route; + type Complete: Send + 'static; + + /// `None` on the first call and whenever the previous step completed without + /// yielding an op; otherwise the result of the op last yielded. + fn resume(&mut self, result: Option>) -> Step<'_, Self>; + + /// The host failed to perform the pending op, or the caller cancelled. The call + /// yields no further ops. + fn interrupt( + &mut self, + failure: HostFailure<::Error>, + ) -> Interrupted<'_, Self>; +} diff --git a/litellm-rust/crates/callbacks/src/route.rs b/litellm-rust/crates/callbacks/src/route.rs new file mode 100644 index 00000000000..97738c8da8b --- /dev/null +++ b/litellm-rust/crates/callbacks/src/route.rs @@ -0,0 +1,9 @@ +/// One public call surface: what a completed call produces, how it fails, and the +/// route-specific operations only its host can perform (request projection, file reads, +/// token acquisition). +pub trait Route: Send + Sync + 'static { + type Response: Send + 'static; + type Error: Clone + Send + Sync + 'static; + type Op: Send + 'static; + type OpResult: Send + 'static; +} diff --git a/litellm-rust/crates/callbacks/src/run.rs b/litellm-rust/crates/callbacks/src/run.rs new file mode 100644 index 00000000000..57bf134f345 --- /dev/null +++ b/litellm-rust/crates/callbacks/src/run.rs @@ -0,0 +1,149 @@ +use crate::event::{CallEvent, FailureOrigin, Timing, epoch_seconds}; +use crate::host::{Host, HostOp, HostResult}; +use crate::machine::{HostFailure, Machine, MachineStep}; +use crate::route::Route; + +/// Drives a machine to completion against an in-process host and emits exactly one +/// terminal event. +pub async fn run(mut machine: M, host: &H) -> Result::Error> +where + M: Machine, + H: Host, +{ + let start_time = epoch_seconds(); + let mut result = None; + let outcome = loop { + let step = match machine.resume(result.take()).await { + Ok(MachineStep::Complete(complete)) => break Ok(complete), + Ok(MachineStep::Host(op)) => op, + Err(error) => break Err(error), + }; + let answer = match step { + HostOp::Route(op) => host.route(op).await.map(HostResult::Route), + HostOp::BeforeSend { wire, context } => host + .before_send(*wire, &context) + .await + .map(|wire| HostResult::BeforeSend(Box::new(wire))), + HostOp::Emit(event) => host.emit(&event).await.map(|()| HostResult::Emitted), + }; + match answer { + Ok(answer) => result = Some(answer), + Err(error) => break machine.interrupt(HostFailure::Error(error)).await, + } + }; + let timing = Timing { + start_time, + end_time: epoch_seconds(), + }; + let terminal = match &outcome { + Ok(_) => CallEvent::Succeeded { timing }, + Err(_) => CallEvent::Failed { + timing, + origin: FailureOrigin::Call, + }, + }; + let _ = host.emit(&terminal).await; + outcome +} + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + + use super::*; + use crate::machine::{Interrupted, Step}; + + struct Unit; + + impl Route for Unit { + type Response = (); + type Error = &'static str; + type Op = &'static str; + type OpResult = (); + } + + struct Scripted { + ops: Vec<&'static str>, + outcome: Result<(), &'static str>, + } + + impl Machine for Scripted { + type Route = Unit; + type Complete = (); + + fn resume(&mut self, _: Option>) -> Step<'_, Self> { + Box::pin(async move { + if !self.ops.is_empty() { + return Ok(MachineStep::Host(HostOp::Route(self.ops.remove(0)))); + } + self.outcome.map(MachineStep::Complete) + }) + } + + fn interrupt(&mut self, failure: HostFailure<&'static str>) -> Interrupted<'_, Self> { + Box::pin(async move { Err(failure.into_error()) }) + } + } + + #[derive(Default)] + struct Recording { + seen: Mutex>, + fail: Option<&'static str>, + } + + impl Host for Recording { + async fn route(&self, op: &'static str) -> Result<(), &'static str> { + self.seen.lock().unwrap().push(format!("route:{op}")); + match self.fail { + Some(failing) if failing == op => Err("host failed"), + _ => Ok(()), + } + } + + async fn emit(&self, event: &CallEvent) -> Result<(), &'static str> { + self.seen.lock().unwrap().push(match event { + CallEvent::Succeeded { .. } => "succeeded".into(), + CallEvent::Failed { .. } => "failed".into(), + other => format!("{other:?}"), + }); + Ok(()) + } + } + + fn scripted(ops: &[&'static str], outcome: Result<(), &'static str>) -> Scripted { + Scripted { + ops: ops.to_vec(), + outcome, + } + } + + #[tokio::test] + async fn forwards_every_op_then_emits_one_succeeded() { + let host = Recording::default(); + let outcome = run(scripted(&["project", "send"], Ok(())), &host).await; + assert_eq!(outcome, Ok(())); + assert_eq!( + *host.seen.lock().unwrap(), + ["route:project", "route:send", "succeeded"] + ); + } + + #[tokio::test] + async fn errors_and_host_failures_each_emit_failed_once() { + let host = Recording::default(); + let outcome = run(scripted(&[], Err("boom")), &host).await; + assert_eq!(outcome, Err("boom")); + assert_eq!(*host.seen.lock().unwrap(), ["failed"]); + + let host = Recording { + fail: Some("send"), + ..Recording::default() + }; + let outcome = run(scripted(&["project", "send", "never"], Ok(())), &host).await; + assert_eq!(outcome, Err("host failed")); + assert_eq!( + *host.seen.lock().unwrap(), + ["route:project", "route:send", "failed"] + ); + } +} diff --git a/litellm-rust/crates/core/AGENTS.md b/litellm-rust/crates/core/AGENTS.md index 541b3b7e3d5..7a7e988b07c 100644 --- a/litellm-rust/crates/core/AGENTS.md +++ b/litellm-rust/crates/core/AGENTS.md @@ -2,7 +2,7 @@ litellm-core is the LiteLLM SDK in Rust — it makes the LLM call. Each top-leve A route module owns the call entrypoint, runtime types, provider/auth/URL resolution, and the handler that performs the HTTP call. Provider code and base config traits live under `src/llms/`, mirroring their Python source paths. This applies to every API surface: shared orchestration stays in its route module (`ocr/`, `chat_completions/`, `messages/`, `audio_transcription/`, or `responses/`), while provider transformations live under the corresponding Python-mirrored `llms//` path. Import implementations directly from their canonical paths; do not add a `src/providers/` layer or compatibility re-exports. Shared provider resolution lives under `src/litellm_core_utils/get_llm_provider_logic.rs`. Handlers belong in core, never in a host crate -Not here: serving HTTP (axum routes, extractors), config file reading, rollout state, databases, or host-specific callback execution. Core owns lifecycle sequencing and callback payload construction; hosts execute the selected integrations. Env reads are limited to credential fallback in a route's `prepare.rs`. +Not here: serving HTTP (axum routes, extractors), config file reading, rollout state, databases, or callback execution of any kind. Core runs each route as a machine that yields host operations and call events; which integrations consume those events is the host's business. Env reads are limited to credential fallback in a route's `prepare.rs`. Routes (messages, ocr, realtime) and providers (anthropic, mistral, openai) are modules, not crates. diff --git a/litellm-rust/crates/core/Cargo.toml b/litellm-rust/crates/core/Cargo.toml index 7a836b4c95a..b9382ac7afd 100644 --- a/litellm-rust/crates/core/Cargo.toml +++ b/litellm-rust/crates/core/Cargo.toml @@ -7,6 +7,7 @@ repository.workspace = true autotests = false [dependencies] +litellm-callbacks.workspace = true bytes.workspace = true futures-util.workspace = true base64.workspace = true @@ -41,3 +42,4 @@ veil.workspace = true aws-smithy-eventstream = "=0.61.1" aws-smithy-types = "1.6.1" rstest.workspace = true +rstest_reuse.workspace = true diff --git a/litellm-rust/crates/core/src/audio_transcription/client.rs b/litellm-rust/crates/core/src/audio_transcription/client.rs index 0e612628dc6..3cf131839b8 100644 --- a/litellm-rust/crates/core/src/audio_transcription/client.rs +++ b/litellm-rust/crates/core/src/audio_transcription/client.rs @@ -1,5 +1,4 @@ -use std::sync::OnceLock; -use std::time::Duration; +use std::{sync::OnceLock, time::Duration}; use crate::constants::AUDIO_TRANSCRIPTION_TIMEOUT_SECS; diff --git a/litellm-rust/crates/core/src/audio_transcription/handler.rs b/litellm-rust/crates/core/src/audio_transcription/handler.rs index ae547f10f15..a7ab93ccd48 100644 --- a/litellm-rust/crates/core/src/audio_transcription/handler.rs +++ b/litellm-rust/crates/core/src/audio_transcription/handler.rs @@ -1,8 +1,6 @@ use serde_json::Value; -use super::Error; -use super::client::http_client; -use super::types::ProviderAudioTranscriptionRequest; +use super::{Error, client::http_client, types::ProviderAudioTranscriptionRequest}; use crate::http_utils::{http_request, truncate_error_body}; pub async fn execute_audio_transcription_provider_call( @@ -44,8 +42,7 @@ async fn signed_headers( request: &ProviderAudioTranscriptionRequest, body: &[u8], ) -> Result, Error> { - use std::collections::BTreeMap; - use std::time::SystemTime; + use std::{collections::BTreeMap, time::SystemTime}; use litellm_auth_aws::{aws_auth_config, resolve_credentials, sign_bedrock_post}; use litellm_providers::base_llm::audio_transcription::transformation::AudioTranscriptionAuth; diff --git a/litellm-rust/crates/core/src/audio_transcription/mod.rs b/litellm-rust/crates/core/src/audio_transcription/mod.rs index b71e8d38b8a..5037fa2322e 100644 --- a/litellm-rust/crates/core/src/audio_transcription/mod.rs +++ b/litellm-rust/crates/core/src/audio_transcription/mod.rs @@ -3,9 +3,8 @@ pub use error::Error; mod client; mod handler; mod prepare; -pub use litellm_providers::audio_transcription::types; - pub use handler::execute_audio_transcription_provider_call; +pub use litellm_providers::audio_transcription::types; pub use prepare::prepare_audio_transcription_provider_call; use serde_json::Value; pub use types::{AudioTranscriptionRequest, ProviderAudioTranscriptionRequest}; diff --git a/litellm-rust/crates/core/src/audio_transcription/prepare.rs b/litellm-rust/crates/core/src/audio_transcription/prepare.rs index 4dc3ffae191..beecdab9615 100644 --- a/litellm-rust/crates/core/src/audio_transcription/prepare.rs +++ b/litellm-rust/crates/core/src/audio_transcription/prepare.rs @@ -1,13 +1,18 @@ -use super::Error; -use super::types::{AudioTranscriptionRequest, ProviderAudioTranscriptionRequest}; -use crate::http_utils::{has_header, string_headers}; -use crate::litellm_core_utils::get_llm_provider_logic::{ - CustomLlmProvider, get_custom_llm_provider, +use litellm_providers::{ + base_llm::audio_transcription::transformation::{ + AudioTranscriptionAuth, BaseAudioTranscriptionConfig, + }, + bedrock::audio_transcription::BEDROCK_AUDIO_TRANSCRIPTION_CONFIG, }; -use litellm_providers::base_llm::audio_transcription::transformation::{ - AudioTranscriptionAuth, BaseAudioTranscriptionConfig, + +use super::{ + Error, + types::{AudioTranscriptionRequest, ProviderAudioTranscriptionRequest}, +}; +use crate::{ + http_utils::{has_header, string_headers}, + litellm_core_utils::get_llm_provider_logic::{CustomLlmProvider, get_custom_llm_provider}, }; -use litellm_providers::bedrock::audio_transcription::BEDROCK_AUDIO_TRANSCRIPTION_CONFIG; fn provider_config(provider: &str) -> Option<&'static dyn BaseAudioTranscriptionConfig> { if provider == "bedrock" { diff --git a/litellm-rust/crates/core/src/audio_transcription/tests.rs b/litellm-rust/crates/core/src/audio_transcription/tests.rs index 263d63337b0..d6491ca8ce0 100644 --- a/litellm-rust/crates/core/src/audio_transcription/tests.rs +++ b/litellm-rust/crates/core/src/audio_transcription/tests.rs @@ -1,11 +1,12 @@ -use std::io::{Read, Write}; -use std::net::TcpListener; -use std::thread; +use std::{ + io::{Read, Write}, + net::TcpListener, + thread, +}; use serde_json::{Map, json}; -use super::audio_transcription; -use super::types::AudioTranscriptionRequest; +use super::{audio_transcription, types::AudioTranscriptionRequest}; #[tokio::test] async fn bedrock_request_is_signed_and_contains_audio() { diff --git a/litellm-rust/crates/core/src/call_arguments.rs b/litellm-rust/crates/core/src/call_arguments.rs index 3b9183c739a..eb1dcd8deb7 100644 --- a/litellm-rust/crates/core/src/call_arguments.rs +++ b/litellm-rust/crates/core/src/call_arguments.rs @@ -37,278 +37,6 @@ pub struct ArgumentSpec { pub secret: bool, } -pub fn should_project(name: &str, consumed: &[ArgumentSpec], bound_fields: &[&str]) -> bool { - consumed.iter().any(|field| field.name == name) - || (!bound_fields.contains(&name) && !is_control(name)) -} - -pub fn is_control(name: &str) -> bool { - crate::params::is_control_param(name) || HOST_CONTROLS.contains(&name) -} - -const HOST_CONTROLS: &[&str] = &[ - "_agentic_loop_api_surface", - "_agentic_loop_depth", - "_agentic_loop_fingerprints", - "_code_interpreter_interception_active", - "_code_interpreter_interception_converted_stream", - "_code_interpreter_interception_sandbox_key", - "_code_interpreter_interception_session_scoped", - "_headroom_interception_converted_stream", - "_litellm_strip_stream_usage", - "_router_weights", - "_websearch_interception_converted_stream", - "_websearch_interception_emit_native_blocks", - "acompletion", - "adaptive_router_config", - "adaptive_router_default_model", - "aembedding", - "aimg_generation", - "allm_passthrough_route", - "allow_client_keepalive_override", - "allowed_model_region", - "allowed_openai_params", - "annotation_cost_per_page", - "api_version", - "arize_api_key", - "arize_space_id", - "arize_space_key", - "assistant_continue_message", - "async_call", - "atext_completion", - "attempted_targets", - "auto_router_config", - "auto_router_config_path", - "auto_router_default_model", - "auto_router_embedding_model", - "auto_router_max_input_chars", - "auto_router_model_compression", - "auto_router_routing_compression", - "aws_batch_role_arn", - "azure", - "azure_password", - "azure_username", - "base_model", - "bedrock_tags", - "bos_token", - "budget_duration", - "cache", - "cache_creation_input_audio_token_cost", - "cache_creation_input_token_cost", - "cache_creation_input_token_cost_above_1hr", - "cache_creation_input_token_cost_above_200k_tokens", - "cache_creation_input_token_cost_above_272k_tokens", - "cache_creation_input_token_cost_above_272k_tokens_flex", - "cache_creation_input_token_cost_above_272k_tokens_priority", - "cache_creation_input_token_cost_flex", - "cache_creation_input_token_cost_priority", - "cache_creation_input_token_cost_ultrafast", - "cache_key", - "cache_read_input_audio_token_cost", - "cache_read_input_token_cost", - "cache_read_input_token_cost_above_200k_tokens", - "cache_read_input_token_cost_above_200k_tokens_priority", - "cache_read_input_token_cost_above_272k_tokens", - "cache_read_input_token_cost_above_272k_tokens_flex", - "cache_read_input_token_cost_above_272k_tokens_priority", - "cache_read_input_token_cost_above_512k_tokens", - "cache_read_input_token_cost_flex", - "cache_read_input_token_cost_priority", - "cache_read_input_token_cost_ultrafast", - "caching", - "caching_groups", - "citation_cost_per_token", - "client", - "client_side_timeout", - "complete_response", - "completion_call_id", - "complexity_router_config", - "complexity_router_default_model", - "configurable_clientside_auth_params", - "context_window_fallback_dict", - "cooldown_time", - "cost_per_query", - "custom_prompt_dict", - "data_residency", - "dd_agent_host", - "dd_agent_port", - "dd_api_key", - "dd_site", - "default_api_key_rpm_limit", - "default_api_key_tpm_limit", - "disable_add_transform_inline_image_block", - "enable_json_schema_validation", - "enable_prompt_caching", - "enable_tag_filtering", - "ensure_alternating_roles", - "eos_token", - "fallback_depth", - "fallbacks", - "fastest_response", - "final_prompt_value", - "force_timeout", - "gcs_bucket_name", - "gcs_path_service_account", - "google_maps_grounding_cost_per_query", - "headers", - "hf_model_name", - "humanloop_api_key", - "id", - "input_cost_per_audio_per_second", - "input_cost_per_audio_per_second_above_128k_tokens", - "input_cost_per_audio_token", - "input_cost_per_audio_token_batches", - "input_cost_per_character", - "input_cost_per_character_above_128k_tokens", - "input_cost_per_image", - "input_cost_per_image_above_128k_tokens", - "input_cost_per_image_token", - "input_cost_per_image_token_batches", - "input_cost_per_pixel", - "input_cost_per_query", - "input_cost_per_second", - "input_cost_per_token", - "input_cost_per_token_above_128k_tokens", - "input_cost_per_token_above_200k_tokens", - "input_cost_per_token_above_200k_tokens_priority", - "input_cost_per_token_above_272k_tokens", - "input_cost_per_token_above_272k_tokens_flex", - "input_cost_per_token_above_272k_tokens_priority", - "input_cost_per_token_above_512k_tokens", - "input_cost_per_token_batches", - "input_cost_per_token_cache_hit", - "input_cost_per_token_flex", - "input_cost_per_token_priority", - "input_cost_per_token_ultrafast", - "input_cost_per_video_per_second", - "input_cost_per_video_per_second_above_128k_tokens", - "input_cost_per_video_per_second_above_15s_interval", - "input_cost_per_video_per_second_above_8s_interval", - "input_cost_per_video_token", - "input_cost_per_video_token_batches", - "itpm", - "keepalive_seconds", - "langfuse_environment", - "langfuse_host", - "langfuse_prompt_version", - "langfuse_public_key", - "langfuse_secret", - "langfuse_secret_key", - "langsmith_api_key", - "langsmith_base_url", - "langsmith_project", - "langsmith_sampling_rate", - "langsmith_tenant_id", - "litellm_credential_name", - "litellm_disabled_callbacks", - "litellm_request_debug", - "litellm_session_id", - "litellm_system_prompt", - "litellm_trace_id", - "litellm_trusted_callback_vars", - "logger_fn", - "max_agentic_loops", - "max_budget", - "max_fallbacks", - "max_parallel_requests", - "merge_reasoning_content_in_choices", - "metadata", - "mock_response", - "mock_timeout", - "model_alias_map", - "model_config", - "model_file_id_mapping", - "model_info", - "model_list", - "newrelic_api_key", - "newrelic_region", - "no-log", - "num_retries", - "ocr_cost_per_credit", - "ocr_cost_per_page", - "order", - "otpm", - "output_cost_per_audio_per_second", - "output_cost_per_audio_token", - "output_cost_per_character", - "output_cost_per_character_above_128k_tokens", - "output_cost_per_image", - "output_cost_per_image_token", - "output_cost_per_pixel", - "output_cost_per_reasoning_token", - "output_cost_per_reasoning_token_flex", - "output_cost_per_reasoning_token_priority", - "output_cost_per_second", - "output_cost_per_second_1080p", - "output_cost_per_second_480p", - "output_cost_per_second_4k", - "output_cost_per_second_720p", - "output_cost_per_token", - "output_cost_per_token_above_128k_tokens", - "output_cost_per_token_above_200k_tokens", - "output_cost_per_token_above_200k_tokens_priority", - "output_cost_per_token_above_272k_tokens", - "output_cost_per_token_above_272k_tokens_flex", - "output_cost_per_token_above_272k_tokens_priority", - "output_cost_per_token_above_512k_tokens", - "output_cost_per_token_batches", - "output_cost_per_token_flex", - "output_cost_per_token_priority", - "output_cost_per_token_ultrafast", - "output_cost_per_video_per_second", - "output_cost_per_video_token", - "output_vector_size", - "posthog_api_key", - "posthog_api_url", - "preset_cache_key", - "prompt_environment", - "prompt_id", - "prompt_label", - "prompt_variables", - "prompt_version", - "provider_specific_header", - "quality_router_config", - "quality_router_default_model", - "region_name", - "regional_endpoint_uplift_multiplier", - "regional_processing_uplift_multiplier_eu", - "regional_processing_uplift_multiplier_us", - "retry_policy", - "retry_strategy", - "roles", - "routing_strategy", - "rpm", - "rust", - "s3_bucket_name", - "s3_output_bucket_name", - "s3_region_name", - "search_context_cost_per_query", - "search_tool_name", - "secret_fields", - "self", - "shared_session", - "ssl_verify", - "stream_response", - "stream_timeout", - "supports_system_message", - "tags", - "text_completion", - "tiered_pricing", - "tpm", - "ttl", - "turn_off_message_logging", - "use_chat_completions_api", - "use_client", - "use_in_pass_through", - "use_litellm_proxy", - "use_xai_oauth", - "user_continue_message", - "verbose", - "wandb_api_key", - "weave_project_id", - "weight", -]; - pub fn compose_body( arguments: &CallArguments, body: &B, @@ -324,9 +52,9 @@ pub fn compose_body( Some(Value::Object(fields)) => Some(fields), Some(_) => return Err(crate::params::Error::ExtraBody), }; - let extensions = arguments.iter().filter(|(name, _)| { - !consumed.contains(&name.as_str()) && name.as_str() != "extra_body" && !is_control(name) - }); + let extensions = arguments + .iter() + .filter(|(name, _)| !consumed.contains(&name.as_str())); Ok(Value::Object( fields .into_iter() @@ -389,7 +117,7 @@ mod tests { fn composition_preserves_extensions_and_applies_shallow_explicit_overrides() { let original = json!({ "known": false, "future": {"old": 1}, "null": null, "zero": 0, - "metadata": {"host": true}, "shared_session": "host", "api_key": "secret", + "metadata": {"host": true}, "timeout": 30, "api_key": "secret", "extra_body": { "known": null, "future": {"new": [false, 0, null]}, "metadata": {"provider": true}, "model": "ignored", "api_key": "ignored" @@ -412,21 +140,6 @@ mod tests { assert_eq!(serde_json::to_value(arguments).unwrap(), original); } - #[test] - fn projection_prioritizes_consumed_fields_and_keeps_unknown_names() { - let fields = [ArgumentSpec { - name: "id", - secret: false, - }]; - assert!(should_project("id", &fields, &[])); - assert!(!should_project("id", &[], &[])); - assert!(should_project("future_option", &[], &[])); - assert!(!should_project("document", &fields, &["document"])); - assert!(!should_project("metadata", &fields, &[])); - assert!(!should_project("callbacks", &fields, &[])); - assert!(!should_project("ocr_cost_per_page", &fields, &[])); - } - #[test] fn invalid_extra_body_is_rejected_without_coercing_it_to_empty() { for value in [json!(false), json!(0), json!([]), json!("")] { diff --git a/litellm-rust/crates/core/src/call_lifecycle/host.rs b/litellm-rust/crates/core/src/call_lifecycle/host.rs deleted file mode 100644 index 97eb9c4c650..00000000000 --- a/litellm-rust/crates/core/src/call_lifecycle/host.rs +++ /dev/null @@ -1,122 +0,0 @@ -use std::future::Future; -use std::pin::Pin; - -pub enum HostCallStep { - Host(O), - Complete(C), -} - -pub type HostCallFuture<'a, O, C, E> = - Pin, E>> + Send + 'a>>; - -pub trait HostCall: Send + Sync { - type Error: Send + Sync + 'static; - type Operation: Send + 'static; - type Result: Send + 'static; - type Complete: Send + 'static; - - fn resume( - &mut self, - result: Option, - ) -> HostCallFuture<'_, Self::Operation, Self::Complete, Self::Error>; - - fn interrupt( - &mut self, - failure: HostFailure, - ) -> HostCallFuture<'_, Self::Operation, Self::Complete, Self::Error>; -} - -pub enum HostStep { - Ready(V), - Suspend(S), -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum HostPhase { - Setup, - DeploymentPreCall, - Prepare, - Execute, - ConstructResponse, - DeploymentPostCall, - Finalize, - Success, - MapFailure, - DeploymentFailure, - Failure, - AsyncFailure, - Complete, -} - -#[derive(Clone, Debug)] -pub enum HostFailure { - Error(E), - Cancelled(E), -} - -pub struct HostLifecycle { - phase: HostPhase, - asynchronous: bool, -} - -impl HostLifecycle { - pub fn new(asynchronous: bool) -> Self { - Self { - phase: HostPhase::Setup, - asynchronous, - } - } - - pub fn phase(&self) -> HostPhase { - self.phase - } - - pub fn accept(&mut self, result: Result<(), HostFailure>) -> Option { - if let Err(failure) = result { - if self.phase == HostPhase::DeploymentFailure { - self.phase = HostPhase::Failure; - return None; - } - let error = match failure { - HostFailure::Cancelled(error) => { - self.phase = HostPhase::Complete; - return Some(error); - } - HostFailure::Error(error) => error, - }; - match self.phase { - HostPhase::Failure | HostPhase::AsyncFailure => { - self.advance(); - return None; - } - HostPhase::Success => self.phase = HostPhase::Complete, - HostPhase::Execute | HostPhase::ConstructResponse => { - self.phase = HostPhase::MapFailure; - } - _ => self.phase = HostPhase::Failure, - } - return Some(error); - } - self.advance(); - None - } - - fn advance(&mut self) { - self.phase = match self.phase { - HostPhase::Setup if self.asynchronous => HostPhase::DeploymentPreCall, - HostPhase::Setup | HostPhase::DeploymentPreCall => HostPhase::Prepare, - HostPhase::Prepare => HostPhase::Execute, - HostPhase::Execute => HostPhase::ConstructResponse, - HostPhase::ConstructResponse if self.asynchronous => HostPhase::DeploymentPostCall, - HostPhase::ConstructResponse | HostPhase::DeploymentPostCall => HostPhase::Finalize, - HostPhase::Finalize => HostPhase::Success, - HostPhase::MapFailure if self.asynchronous => HostPhase::DeploymentFailure, - HostPhase::MapFailure | HostPhase::DeploymentFailure => HostPhase::Failure, - HostPhase::Failure if self.asynchronous => HostPhase::AsyncFailure, - HostPhase::Failure - | HostPhase::AsyncFailure - | HostPhase::Success - | HostPhase::Complete => HostPhase::Complete, - }; - } -} diff --git a/litellm-rust/crates/core/src/call_lifecycle/mod.rs b/litellm-rust/crates/core/src/call_lifecycle/mod.rs deleted file mode 100644 index e012961e005..00000000000 --- a/litellm-rust/crates/core/src/call_lifecycle/mod.rs +++ /dev/null @@ -1,427 +0,0 @@ -use std::future::Future; -use std::time::{Instant, SystemTime, UNIX_EPOCH}; - -pub mod host; -#[cfg(test)] -#[path = "../../tests/host_lifecycle.rs"] -mod host_tests; -pub mod types; - -pub use types::{ - CallLifecycleContext, CallLifecyclePhase, CallLifecyclePhaseTiming, CallLifecycleRequest, - CallLifecycleTiming, -}; - -pub trait CallLifecycleHooks: Send + Sync { - type Error: Send + Sync; - type PreCallFuture<'a>: Future> + Send + 'a - where - Self: 'a, - InitialReq: 'a, - ProviderReq: 'a, - Resp: 'a; - - type DuringCallFuture<'a>: Future> + Send + 'a - where - Self: 'a, - InitialReq: 'a, - ProviderReq: 'a, - Resp: 'a; - - type SuccessFuture<'a>: Future + Send + 'a - where - Self: 'a, - Resp: 'a; - - type FailureFuture<'a>: Future + Send + 'a - where - Self: 'a; - - fn async_pre_call_hook<'a>( - &'a self, - context: &'a CallLifecycleContext, - request: InitialReq, - ) -> Self::PreCallFuture<'a>; - - fn async_during_call_hook<'a>( - &'a self, - context: &'a CallLifecycleContext, - request: InitialReq, - ) -> Self::DuringCallFuture<'a>; - - fn async_log_success_event<'a>( - &'a self, - context: &'a CallLifecycleContext, - response: &'a Resp, - timing: &'a CallLifecycleTiming, - ) -> Self::SuccessFuture<'a>; - - fn async_log_failure_event<'a>( - &'a self, - context: &'a CallLifecycleContext, - error: &'a Self::Error, - timing: &'a CallLifecycleTiming, - ) -> Self::FailureFuture<'a>; -} - -pub trait CallLifecycleObserver: Send + Sync { - fn on_phase_start(&self, _context: &CallLifecycleContext, _phase: CallLifecyclePhase) {} - - fn on_phase_end(&self, _context: &CallLifecycleContext, _timing: &CallLifecyclePhaseTiming) {} -} - -#[derive(Default)] -pub struct NoopCallLifecycleObserver; - -impl CallLifecycleObserver for NoopCallLifecycleObserver {} - -pub struct CallLifecycle<'a> { - observer: &'a dyn CallLifecycleObserver, -} - -impl<'a> CallLifecycle<'a> { - pub fn new(observer: &'a dyn CallLifecycleObserver) -> Self { - Self { observer } - } - - pub async fn run_request( - &self, - request: InitialReq, - hooks: &Hooks, - provider_call: ProviderCall, - ) -> Result - where - InitialReq: CallLifecycleRequest, - Hooks: CallLifecycleHooks, - ProviderCall: FnOnce(ProviderReq) -> ProviderFuture, - ProviderFuture: Future>, - { - let context = request.lifecycle_context(); - self.run(context, request, hooks, provider_call).await - } - - pub async fn run( - &self, - context: CallLifecycleContext, - request: InitialReq, - hooks: &Hooks, - provider_call: ProviderCall, - ) -> Result - where - Hooks: CallLifecycleHooks, - ProviderCall: FnOnce(ProviderReq) -> ProviderFuture, - ProviderFuture: Future>, - { - let call_start = epoch_seconds(); - let mut phases = Vec::new(); - - let pre_call = self.start_phase(&context, CallLifecyclePhase::PreCall); - let request = match hooks.async_pre_call_hook(&context, request).await { - Ok(request) => { - phases.push(self.finish_phase(&context, pre_call)); - request - } - Err(error) => { - phases.push(self.finish_phase(&context, pre_call)); - self.log_failure(&context, hooks, &error, call_start, &mut phases) - .await; - return Err(error); - } - }; - - let during_call = self.start_phase(&context, CallLifecyclePhase::DuringCall); - let provider_request = match hooks.async_during_call_hook(&context, request).await { - Ok(request) => { - phases.push(self.finish_phase(&context, during_call)); - request - } - Err(error) => { - phases.push(self.finish_phase(&context, during_call)); - self.log_failure(&context, hooks, &error, call_start, &mut phases) - .await; - return Err(error); - } - }; - - let provider_phase = self.start_phase(&context, CallLifecyclePhase::ProviderCall); - let result = provider_call(provider_request).await; - phases.push(self.finish_phase(&context, provider_phase)); - - match &result { - Ok(response) => { - let success_phase = self.start_phase(&context, CallLifecyclePhase::SuccessCallback); - let timing = CallLifecycleTiming::new(call_start, epoch_seconds(), phases.clone()); - hooks - .async_log_success_event(&context, response, &timing) - .await; - phases.push(self.finish_phase(&context, success_phase)); - } - Err(error) => { - self.log_failure(&context, hooks, error, call_start, &mut phases) - .await; - } - } - - result - } - - async fn log_failure( - &self, - context: &CallLifecycleContext, - hooks: &Hooks, - error: &Hooks::Error, - call_start: f64, - phases: &mut Vec, - ) where - Hooks: CallLifecycleHooks, - { - let failure_phase = self.start_phase(context, CallLifecyclePhase::FailureCallback); - let timing = CallLifecycleTiming::new(call_start, epoch_seconds(), phases.clone()); - hooks.async_log_failure_event(context, error, &timing).await; - phases.push(self.finish_phase(context, failure_phase)); - } - - fn start_phase(&self, context: &CallLifecycleContext, phase: CallLifecyclePhase) -> PhaseStart { - self.observer.on_phase_start(context, phase); - PhaseStart { - phase, - start_time: epoch_seconds(), - started_at: Instant::now(), - } - } - - fn finish_phase( - &self, - context: &CallLifecycleContext, - phase_start: PhaseStart, - ) -> CallLifecyclePhaseTiming { - let timing = CallLifecyclePhaseTiming { - phase: phase_start.phase, - start_time: phase_start.start_time, - end_time: epoch_seconds(), - duration: phase_start.started_at.elapsed(), - }; - self.observer.on_phase_end(context, &timing); - timing - } -} - -impl Default for CallLifecycle<'static> { - fn default() -> Self { - static OBSERVER: NoopCallLifecycleObserver = NoopCallLifecycleObserver; - Self::new(&OBSERVER) - } -} - -struct PhaseStart { - phase: CallLifecyclePhase, - start_time: f64, - started_at: Instant, -} - -fn epoch_seconds() -> f64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_secs_f64()) - .unwrap_or(0.0) -} - -#[cfg(test)] -mod tests { - use std::pin::Pin; - use std::sync::Mutex; - - use super::*; - - type BoxFuture<'a, T> = Pin + Send + 'a>>; - - #[derive(Default)] - struct RecordingHooks { - events: Mutex>, - } - - struct RecordingRequest(String); - - impl CallLifecycleRequest for RecordingRequest { - fn lifecycle_context(&self) -> CallLifecycleContext { - CallLifecycleContext::new("ocr", "mistral-ocr-latest", "mistral", "call_1") - } - } - - impl RecordingHooks { - fn events(&self) -> Vec<&'static str> { - self.events.lock().unwrap().clone() - } - } - - impl CallLifecycleHooks for RecordingHooks { - type Error = crate::messages::Error; - type PreCallFuture<'a> = BoxFuture<'a, Result>; - type DuringCallFuture<'a> = BoxFuture<'a, Result>; - type SuccessFuture<'a> = BoxFuture<'a, ()>; - type FailureFuture<'a> = BoxFuture<'a, ()>; - - fn async_pre_call_hook<'a>( - &'a self, - _context: &'a CallLifecycleContext, - request: String, - ) -> Self::PreCallFuture<'a> { - Box::pin(async move { - self.events.lock().unwrap().push("pre_call"); - Ok(format!("{request}:pre")) - }) - } - - fn async_during_call_hook<'a>( - &'a self, - _context: &'a CallLifecycleContext, - request: String, - ) -> Self::DuringCallFuture<'a> { - Box::pin(async move { - self.events.lock().unwrap().push("during_call"); - Ok(format!("{request}:during")) - }) - } - - fn async_log_success_event<'a>( - &'a self, - _context: &'a CallLifecycleContext, - _response: &'a String, - timing: &'a CallLifecycleTiming, - ) -> Self::SuccessFuture<'a> { - Box::pin(async move { - assert!(timing.end_time >= timing.start_time); - assert_eq!(timing.phases.len(), 3); - self.events.lock().unwrap().push("success"); - }) - } - - fn async_log_failure_event<'a>( - &'a self, - _context: &'a CallLifecycleContext, - _error: &'a crate::messages::Error, - _timing: &'a CallLifecycleTiming, - ) -> Self::FailureFuture<'a> { - Box::pin(async move { - self.events.lock().unwrap().push("failure"); - }) - } - } - - impl CallLifecycleHooks for RecordingHooks { - type Error = crate::messages::Error; - type PreCallFuture<'a> = BoxFuture<'a, Result>; - type DuringCallFuture<'a> = BoxFuture<'a, Result>; - type SuccessFuture<'a> = BoxFuture<'a, ()>; - type FailureFuture<'a> = BoxFuture<'a, ()>; - - fn async_pre_call_hook<'a>( - &'a self, - _context: &'a CallLifecycleContext, - request: RecordingRequest, - ) -> Self::PreCallFuture<'a> { - Box::pin(async move { - self.events.lock().unwrap().push("pre_call"); - Ok(RecordingRequest(format!("{}:pre", request.0))) - }) - } - - fn async_during_call_hook<'a>( - &'a self, - _context: &'a CallLifecycleContext, - request: RecordingRequest, - ) -> Self::DuringCallFuture<'a> { - Box::pin(async move { - self.events.lock().unwrap().push("during_call"); - Ok(format!("{}:during", request.0)) - }) - } - - fn async_log_success_event<'a>( - &'a self, - _context: &'a CallLifecycleContext, - _response: &'a String, - _timing: &'a CallLifecycleTiming, - ) -> Self::SuccessFuture<'a> { - Box::pin(async move { - self.events.lock().unwrap().push("success"); - }) - } - - fn async_log_failure_event<'a>( - &'a self, - _context: &'a CallLifecycleContext, - _error: &'a crate::messages::Error, - _timing: &'a CallLifecycleTiming, - ) -> Self::FailureFuture<'a> { - Box::pin(async move { - self.events.lock().unwrap().push("failure"); - }) - } - } - - #[tokio::test] - async fn lifecycle_runs_hooks_around_provider_call() { - let hooks = RecordingHooks::default(); - let response = CallLifecycle::default() - .run( - CallLifecycleContext::new("ocr", "mistral-ocr-latest", "mistral", "call_1"), - "request".to_string(), - &hooks, - |request| async move { - assert_eq!(request, "request:pre:during"); - Ok("response".to_string()) - }, - ) - .await - .expect("call succeeds"); - - assert_eq!(response, "response"); - assert_eq!(hooks.events(), vec!["pre_call", "during_call", "success"]); - } - - #[tokio::test] - async fn lifecycle_logs_failure_when_provider_fails() { - let hooks = RecordingHooks::default(); - let error = CallLifecycle::default() - .run( - CallLifecycleContext::new("ocr", "mistral-ocr-latest", "mistral", "call_1"), - "request".to_string(), - &hooks, - |_request| async move { - Err::(crate::messages::Error::Transport( - crate::transport::Error::Network("provider down".to_string()), - )) - }, - ) - .await - .expect_err("call fails"); - - assert_eq!( - error, - crate::messages::Error::Transport(crate::transport::Error::Network( - "provider down".to_string() - )) - ); - assert_eq!(hooks.events(), vec!["pre_call", "during_call", "failure"]); - } - - #[tokio::test] - async fn lifecycle_can_run_any_request_with_embedded_context() { - let hooks = RecordingHooks::default(); - let response = CallLifecycle::default() - .run_request( - RecordingRequest("request".to_string()), - &hooks, - |request| async move { - assert_eq!(request, "request:pre:during"); - Ok("response".to_string()) - }, - ) - .await - .expect("call succeeds"); - - assert_eq!(response, "response"); - assert_eq!(hooks.events(), vec!["pre_call", "during_call", "success"]); - } -} diff --git a/litellm-rust/crates/core/src/call_lifecycle/types.rs b/litellm-rust/crates/core/src/call_lifecycle/types.rs deleted file mode 100644 index 8819c8830d2..00000000000 --- a/litellm-rust/crates/core/src/call_lifecycle/types.rs +++ /dev/null @@ -1,75 +0,0 @@ -use std::time::Duration; - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CallLifecycleContext { - pub call_type: String, - pub model: String, - pub custom_llm_provider: String, - pub litellm_call_id: String, -} - -impl CallLifecycleContext { - pub fn new( - call_type: impl Into, - model: impl Into, - custom_llm_provider: impl Into, - litellm_call_id: impl Into, - ) -> Self { - Self { - call_type: call_type.into(), - model: model.into(), - custom_llm_provider: custom_llm_provider.into(), - litellm_call_id: litellm_call_id.into(), - } - } -} - -pub trait CallLifecycleRequest { - fn lifecycle_context(&self) -> CallLifecycleContext; -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum CallLifecyclePhase { - PreCall, - DuringCall, - ProviderCall, - SuccessCallback, - FailureCallback, -} - -impl CallLifecyclePhase { - pub fn as_str(self) -> &'static str { - match self { - Self::PreCall => "pre_call", - Self::DuringCall => "during_call", - Self::ProviderCall => "provider_call", - Self::SuccessCallback => "success_callback", - Self::FailureCallback => "failure_callback", - } - } -} - -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct CallLifecyclePhaseTiming { - pub phase: CallLifecyclePhase, - pub start_time: f64, - pub end_time: f64, - pub duration: Duration, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct CallLifecycleTiming { - pub start_time: f64, - pub end_time: f64, - pub phases: Vec, -} - -impl CallLifecycleTiming { - pub fn new(start_time: f64, end_time: f64, phases: Vec) -> Self { - Self { - start_time, - end_time, - phases, - } - } -} diff --git a/litellm-rust/crates/core/src/chat_completions/client.rs b/litellm-rust/crates/core/src/chat_completions/client.rs index f2ef73ed030..d8ad6c49b7b 100644 --- a/litellm-rust/crates/core/src/chat_completions/client.rs +++ b/litellm-rust/crates/core/src/chat_completions/client.rs @@ -1,5 +1,4 @@ -use std::sync::OnceLock; -use std::time::Duration; +use std::{sync::OnceLock, time::Duration}; use crate::constants::{CHAT_COMPLETIONS_CONNECT_TIMEOUT_SECS, CHAT_COMPLETIONS_TIMEOUT_SECS}; diff --git a/litellm-rust/crates/core/src/chat_completions/common_utils.rs b/litellm-rust/crates/core/src/chat_completions/common_utils.rs index 63fc899e6f4..309cc781cc0 100644 --- a/litellm-rust/crates/core/src/chat_completions/common_utils.rs +++ b/litellm-rust/crates/core/src/chat_completions/common_utils.rs @@ -1,9 +1,11 @@ +use litellm_providers::{ + anthropic::chat::transformation::ANTHROPIC_CHAT_COMPLETIONS_CONFIG, + base_llm::chat::transformation::BaseConfig, +}; use serde_json::{Map, Value}; use super::Error; use crate::http_utils::string_headers as shared_string_headers; -use litellm_providers::anthropic::chat::transformation::ANTHROPIC_CHAT_COMPLETIONS_CONFIG; -use litellm_providers::base_llm::chat::transformation::BaseConfig; const HEADER_CONTEXT: &str = "chat completions"; diff --git a/litellm-rust/crates/core/src/chat_completions/handler.rs b/litellm-rust/crates/core/src/chat_completions/handler.rs index ac9f58cda22..d9939177f31 100644 --- a/litellm-rust/crates/core/src/chat_completions/handler.rs +++ b/litellm-rust/crates/core/src/chat_completions/handler.rs @@ -1,14 +1,16 @@ +use litellm_providers::base_llm::chat::transformation::ChatCompletionsAuth; use serde_json::Value; -use super::Error; -use super::client::http_client; -use super::prepare::prepare_provider_request; -use super::types::{ - ChatCompletionsResponse, ProviderChatCompletionsRequest, ProviderChatResponseData, - ResolvedChatCompletionsRequest, +use super::{ + Error, + client::http_client, + prepare::prepare_provider_request, + types::{ + ChatCompletionsResponse, ProviderChatCompletionsRequest, ProviderChatResponseData, + ResolvedChatCompletionsRequest, + }, }; use crate::http_utils::{http_request, truncate_error_body}; -use litellm_providers::base_llm::chat::transformation::ChatCompletionsAuth; pub(super) async fn execute_chat_completions_provider_call( request: ResolvedChatCompletionsRequest<'_>, @@ -84,8 +86,7 @@ pub(super) async fn signed_headers( request: &ProviderChatCompletionsRequest, body: &[u8], ) -> Result, Error> { - use std::collections::BTreeMap; - use std::time::SystemTime; + use std::{collections::BTreeMap, time::SystemTime}; use litellm_auth_aws::{ aws_auth_config, aws_signature_headers, host_supplied_credentials, diff --git a/litellm-rust/crates/core/src/chat_completions/mod.rs b/litellm-rust/crates/core/src/chat_completions/mod.rs index 2215e1d9c5b..2fd619f9f93 100644 --- a/litellm-rust/crates/core/src/chat_completions/mod.rs +++ b/litellm-rust/crates/core/src/chat_completions/mod.rs @@ -14,9 +14,8 @@ pub use litellm_providers::chat::{conversation, response_utils}; pub(crate) mod handler; mod prepare; pub mod streaming; -pub use litellm_providers::chat::types; - use handler::execute_chat_completions_provider_call; +pub use litellm_providers::chat::types; use prepare::{parse_messages, resolve_provider_config, resolve_request}; use serde_json::{Map, Value}; use types::{ChatCompletionsRequest, ChatCompletionsResponse}; diff --git a/litellm-rust/crates/core/src/chat_completions/prepare.rs b/litellm-rust/crates/core/src/chat_completions/prepare.rs index 3f3f97d6191..d7b2a58596f 100644 --- a/litellm-rust/crates/core/src/chat_completions/prepare.rs +++ b/litellm-rust/crates/core/src/chat_completions/prepare.rs @@ -1,16 +1,18 @@ +use litellm_providers::base_llm::chat::transformation::{BaseConfig, ChatCompletionsAuth}; use serde_json::Value; -use super::Error; -use super::common_utils::{chat_completions_provider_config, string_headers}; -use super::types::{ - ChatCompletionsRequest, ChatMessage, ProviderChatCompletionsRequest, - ResolvedChatCompletionsRequest, +use super::{ + Error, + common_utils::{chat_completions_provider_config, string_headers}, + types::{ + ChatCompletionsRequest, ChatMessage, ProviderChatCompletionsRequest, + ResolvedChatCompletionsRequest, + }, }; -use crate::http_utils::has_header; -use crate::litellm_core_utils::get_llm_provider_logic::{ - CustomLlmProvider, get_custom_llm_provider, +use crate::{ + http_utils::has_header, + litellm_core_utils::get_llm_provider_logic::{CustomLlmProvider, get_custom_llm_provider}, }; -use litellm_providers::base_llm::chat::transformation::{BaseConfig, ChatCompletionsAuth}; pub(super) fn resolve_provider_config<'a>( model: &'a str, diff --git a/litellm-rust/crates/core/src/chat_completions/tests.rs b/litellm-rust/crates/core/src/chat_completions/tests.rs index 40298cf5c2e..e9f1451022e 100644 --- a/litellm-rust/crates/core/src/chat_completions/tests.rs +++ b/litellm-rust/crates/core/src/chat_completions/tests.rs @@ -1,9 +1,11 @@ +use litellm_providers::base_llm::chat::transformation::ChatCompletionsAuth; use serde_json::{Map, Value, json}; -use super::Error; -use super::prepare::{prepare_provider_request, resolve_request}; -use super::types::{ChatCompletionsRequest, ProviderChatCompletionsRequest}; -use litellm_providers::base_llm::chat::transformation::ChatCompletionsAuth; +use super::{ + Error, + prepare::{prepare_provider_request, resolve_request}, + types::{ChatCompletionsRequest, ProviderChatCompletionsRequest}, +}; fn prepare_chat_completions_call( request: ChatCompletionsRequest<'_>, @@ -587,8 +589,10 @@ fn the_gate_agrees_with_prepare_on_every_case_it_accepts() { } mod round_trip { - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - use tokio::net::{TcpListener, TcpStream}; + use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::{TcpListener, TcpStream}, + }; use super::*; use crate::chat_completions::chat_completions; diff --git a/litellm-rust/crates/core/src/lib.rs b/litellm-rust/crates/core/src/lib.rs index 6d540ceaa6f..b1474f3f6c4 100644 --- a/litellm-rust/crates/core/src/lib.rs +++ b/litellm-rust/crates/core/src/lib.rs @@ -1,12 +1,12 @@ pub mod audio_transcription; pub mod call_arguments; -pub mod call_lifecycle; pub mod chat_completions; pub mod constants; pub mod error; pub mod http_utils; pub mod litellm_core_utils; pub mod llms; +pub mod machine; mod media; pub mod messages; pub mod ocr; diff --git a/litellm-rust/crates/core/src/llms/anthropic/chat/streaming.rs b/litellm-rust/crates/core/src/llms/anthropic/chat/streaming.rs index 427c57633d3..2c540a4c436 100644 --- a/litellm-rust/crates/core/src/llms/anthropic/chat/streaming.rs +++ b/litellm-rust/crates/core/src/llms/anthropic/chat/streaming.rs @@ -6,11 +6,13 @@ use super::super::experimental_pass_through::messages::streaming::{ AnthropicContentBlock, AnthropicContentBlockDelta, AnthropicMessagesStreamEvent, AnthropicStreamUsage, }; -use crate::chat_completions::Error; -use crate::chat_completions::streaming::StreamTransformer; -use crate::chat_completions::types::{ - ChatCompletionChunk, ChatCompletionThinkingBlock, ChatCompletionToolCallChunk, - ChatCompletionsUsage, +use crate::chat_completions::{ + Error, + streaming::StreamTransformer, + types::{ + ChatCompletionChunk, ChatCompletionThinkingBlock, ChatCompletionToolCallChunk, + ChatCompletionsUsage, + }, }; #[derive(Clone, Copy, Debug, Eq, PartialEq)] diff --git a/litellm-rust/crates/core/src/llms/anthropic/experimental_pass_through/messages/batches.rs b/litellm-rust/crates/core/src/llms/anthropic/experimental_pass_through/messages/batches.rs index 16b4e2a59ad..8a314bd3e56 100644 --- a/litellm-rust/crates/core/src/llms/anthropic/experimental_pass_through/messages/batches.rs +++ b/litellm-rust/crates/core/src/llms/anthropic/experimental_pass_through/messages/batches.rs @@ -1,11 +1,10 @@ +use litellm_providers::anthropic::experimental_pass_through::messages::transformation::resolve_anthropic_api_base; use serde::{Deserialize, Serialize}; use serde_json::Value; use time::OffsetDateTime; use url::Url; -use crate::messages::Error; -use crate::messages::types::AnthropicMessagesResponse; -use litellm_providers::anthropic::experimental_pass_through::messages::transformation::resolve_anthropic_api_base; +use crate::messages::{Error, types::AnthropicMessagesResponse}; const BATCHES_PATH_SUFFIX: &str = "/v1/messages/batches"; diff --git a/litellm-rust/crates/core/src/llms/anthropic/experimental_pass_through/messages/count_tokens.rs b/litellm-rust/crates/core/src/llms/anthropic/experimental_pass_through/messages/count_tokens.rs index 8ad96e2ead5..3e599f67eb3 100644 --- a/litellm-rust/crates/core/src/llms/anthropic/experimental_pass_through/messages/count_tokens.rs +++ b/litellm-rust/crates/core/src/llms/anthropic/experimental_pass_through/messages/count_tokens.rs @@ -1,9 +1,13 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; -use crate::constants::ANTHROPIC_OAUTH_TOKEN_PREFIX; -use crate::messages::Error; -use crate::messages::types::{AnthropicMessage, SystemPrompt}; +use crate::{ + constants::ANTHROPIC_OAUTH_TOKEN_PREFIX, + messages::{ + Error, + types::{AnthropicMessage, SystemPrompt}, + }, +}; const COUNT_TOKENS_ENDPOINT: &str = "https://api.anthropic.com/v1/messages/count_tokens"; const TOKEN_COUNTING_BETA: &str = "token-counting-2024-11-01"; diff --git a/litellm-rust/crates/core/src/llms/anthropic/experimental_pass_through/messages/streaming.rs b/litellm-rust/crates/core/src/llms/anthropic/experimental_pass_through/messages/streaming.rs index ab087e50805..92a36265df7 100644 --- a/litellm-rust/crates/core/src/llms/anthropic/experimental_pass_through/messages/streaming.rs +++ b/litellm-rust/crates/core/src/llms/anthropic/experimental_pass_through/messages/streaming.rs @@ -1,9 +1,11 @@ use base64::Engine; use bytes::Buf; use futures_util::{Stream, StreamExt}; -use litellm_framing::Framer; -use litellm_framing::aws_event_stream::{AwsEventStreamFrame, AwsEventStreamFramer}; -use litellm_framing::sse::{SseFrame, SseFramer}; +use litellm_framing::{ + Framer, + aws_event_stream::{AwsEventStreamFrame, AwsEventStreamFramer}, + sse::{SseFrame, SseFramer}, +}; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; diff --git a/litellm-rust/crates/core/src/llms/azure_ai/ocr/cohere_parse_transformation.rs b/litellm-rust/crates/core/src/llms/azure_ai/ocr/cohere_parse_transformation.rs index 0b60c793c9d..71ea7a279a6 100644 --- a/litellm-rust/crates/core/src/llms/azure_ai/ocr/cohere_parse_transformation.rs +++ b/litellm-rust/crates/core/src/llms/azure_ai/ocr/cohere_parse_transformation.rs @@ -1,13 +1,22 @@ use serde_json::Value; -use crate::call_arguments::CallArguments; -use crate::llms::base_llm::ocr::transformation::{BaseOcrConfig, OcrRequestContext}; -use crate::llms::cohere::ocr::transformation::{CohereParseConfig, CohereRequest}; -use crate::llms::cohere::ocr::{CohereOptions, validate_document}; -use crate::ocr::OcrClient; -use crate::ocr::document::{inline_remote_document, validate_inline_document}; -use crate::ocr::types::{LiteLLMOcrResponse, OcrDocument, PreparedOcrRequest}; -use crate::url_utils::ApiUrl; +use crate::{ + call_arguments::CallArguments, + llms::{ + base_llm::ocr::transformation::{BaseOcrConfig, OcrRequestContext}, + cohere::ocr::{ + CohereOptions, + transformation::{CohereParseConfig, CohereRequest}, + validate_document, + }, + }, + ocr::{ + OcrClient, + document::{inline_remote_document, validate_inline_document}, + types::{LiteLLMOcrResponse, OcrDocument, PreparedOcrRequest}, + }, + url_utils::ApiUrl, +}; #[derive(Default)] pub(crate) struct AzureAICohereParseConfig; diff --git a/litellm-rust/crates/core/src/llms/azure_ai/ocr/document_intelligence/transformation.rs b/litellm-rust/crates/core/src/llms/azure_ai/ocr/document_intelligence/transformation.rs index 78841274f39..7ad4b4d120f 100644 --- a/litellm-rust/crates/core/src/llms/azure_ai/ocr/document_intelligence/transformation.rs +++ b/litellm-rust/crates/core/src/llms/azure_ai/ocr/document_intelligence/transformation.rs @@ -1,6 +1,4 @@ -use std::collections::BTreeSet; -use std::sync::Arc; -use std::time::Duration; +use std::{collections::BTreeSet, time::Duration}; use base64::{Engine, engine::general_purpose::STANDARD}; use litellm_auth::{InputSource, Sourced}; @@ -11,26 +9,31 @@ use serde_json::{Map, Value}; use serde_with::serde_as; use tokio::time::Instant; -use crate::call_arguments::CallArguments; -use crate::constants::{ - AZURE_DI_API_VERSION, AZURE_DI_DEFAULT_DPI, AZURE_DI_DEFAULT_HEIGHT, AZURE_DI_DEFAULT_WIDTH, - AZURE_DI_SUBSCRIPTION_HEADER, OCR_POLL_RETRY_SECS, +use crate::{ + call_arguments::CallArguments, + constants::{ + AZURE_DI_API_VERSION, AZURE_DI_DEFAULT_DPI, AZURE_DI_DEFAULT_HEIGHT, + AZURE_DI_DEFAULT_WIDTH, AZURE_DI_SUBSCRIPTION_HEADER, OCR_POLL_RETRY_SECS, + }, + llms::base_llm::ocr::transformation::{ + BaseOcrConfig, OcrResponseContext, decode_and_normalize_response, + }, + ocr::{ + OcrClient, + client::read_json_response, + document::InlineDocument, + json::DecodedOcrResponse, + prepare::credential_env, + route::OcrHost, + types::{ + LiteLLMOcrResponse, OcrConnection, OcrCredentialInputs, OcrDocument, OcrPage, + OcrPageDimensions, OcrResponseFormat, OcrUsageInfo, PreparedOcrRequest, + ResolvedOcrCredentials, + }, + }, + serde_compat::{FiniteF64, LaxI64}, + url_utils::ApiUrl, }; -use crate::llms::base_llm::ocr::transformation::{ - BaseOcrConfig, OcrResponseContext, decode_and_normalize_response, -}; -use crate::ocr::OcrClient; -use crate::ocr::client::read_json_response; -use crate::ocr::document::InlineDocument; -use crate::ocr::hooks::OcrHooks; -use crate::ocr::json::DecodedOcrResponse; -use crate::ocr::prepare::credential_env; -use crate::ocr::types::{ - LiteLLMOcrResponse, OcrConnection, OcrCredentialInputs, OcrDocument, OcrPage, - OcrPageDimensions, OcrResponseFormat, OcrUsageInfo, PreparedOcrRequest, ResolvedOcrCredentials, -}; -use crate::serde_compat::{FiniteF64, LaxI64}; -use crate::url_utils::ApiUrl; const AZURE_DI_API_KEY_ENV: &str = "AZURE_DOCUMENT_INTELLIGENCE_API_KEY"; const AZURE_DI_ENDPOINT_ENV: &str = "AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT"; @@ -235,7 +238,7 @@ impl BaseOcrConfig for AzureDocumentIntelligenceOcrConfig { context.headers, context.connection, context.request_format == OcrResponseFormat::Native, - context.hooks, + context.host, ) .await?; Ok(LiteLLMOcrResponse { @@ -439,13 +442,13 @@ async fn read_operation_response( headers: &[(String, String)], connection: &OcrConnection, native: bool, - hooks: &Arc, + host: &OcrHost, ) -> Result, crate::ocr::Error> { if response.status() != reqwest::StatusCode::ACCEPTED { let bytes = crate::ocr::client::read_response_bytes(response, connection.max_response_bytes) .await?; - crate::ocr::handler::post_call(hooks, &bytes).await?; + crate::ocr::handler::emit_response_received(host, &bytes).await?; return crate::ocr::json::decode_response(&bytes, native); } let location = response @@ -464,8 +467,8 @@ async fn read_operation_response( } let bytes = crate::ocr::client::read_response_bytes(response, connection.max_response_bytes).await?; - crate::ocr::handler::post_call(hooks, &bytes).await?; - poll_operation(http_client, operation, headers, connection, native, hooks).await + crate::ocr::handler::emit_response_received(host, &bytes).await?; + poll_operation(http_client, operation, headers, connection, native, host).await } async fn poll_operation( @@ -474,7 +477,7 @@ async fn poll_operation( headers: &[(String, String)], connection: &OcrConnection, native: bool, - hooks: &Arc, + host: &OcrHost, ) -> Result, crate::ocr::Error> { let deadline = Instant::now() .checked_add(connection.poll_timeout) @@ -516,7 +519,7 @@ async fn poll_operation( .map_err(|_| crate::ocr::Error::PollTimeout)??; match &decoded.data.status { Some(OperationStatus::Succeeded) => { - crate::ocr::handler::post_call(hooks, decoded.text.as_bytes()).await?; + crate::ocr::handler::emit_response_received(host, decoded.text.as_bytes()).await?; return Ok(decoded); } Some(OperationStatus::Running | OperationStatus::NotStarted) => { @@ -807,7 +810,12 @@ mod tests { use std::sync::{Arc, Mutex}; - use crate::ocr::test_support::{MockResponse, mock_server, perform_ocr, wire_request}; + use litellm_callbacks::event::CallEvent; + + use crate::ocr::{ + LocalOcrHost, + test_support::{MockResponse, mock_server, perform_ocr, perform_ocr_with, wire_request}, + }; fn query_value(url: &str, key: &str) -> Option { url::Url::parse(url) @@ -981,28 +989,8 @@ mod tests { } } - struct SubmissionBoundary { - request_count: Arc>>, - post_calls: Arc>>, - } - - impl crate::ocr::hooks::OcrHooks for SubmissionBoundary { - fn post_call( - &self, - request: crate::ocr::hooks::OcrPostCallRequest, - ) -> crate::ocr::hooks::OcrHookFuture<'_, crate::ocr::hooks::OcrPostCallRequest> { - Box::pin(async move { - self.post_calls.lock().unwrap().push(( - self.request_count.lock().unwrap().len(), - request.original_response.clone(), - )); - Ok(request) - }) - } - } - #[tokio::test] - async fn accepted_response_runs_post_call_for_submission_and_completed_poll() { + async fn accepted_response_emits_response_received_for_submission_and_completed_poll() { let (base, seen, server) = mock_server(vec![ MockResponse { status: 202, @@ -1012,23 +1000,31 @@ mod tests { MockResponse::json(json!({"status":"succeeded"})), ]) .await; - let post_calls = Arc::new(Mutex::new(Vec::new())); - let request = crate::ocr::LiteLLMOcrRequest { - hooks: Arc::new(SubmissionBoundary { - request_count: seen.clone(), - post_calls: post_calls.clone(), - }), - ..wire_request("azure_ai/doc-intelligence/prebuilt-read", &base, json!({})) - }; + let responses_received = Arc::new(Mutex::new(Vec::new())); + let request_count = seen.clone(); + let observed = responses_received.clone(); + let host = LocalOcrHost::new(wire_request( + "azure_ai/doc-intelligence/prebuilt-read", + &base, + json!({}), + )) + .with_observer(move |event| { + if let CallEvent::ResponseReceived { raw } = event { + observed + .lock() + .unwrap() + .push((request_count.lock().unwrap().len(), raw.body.clone())); + } + }); - perform_ocr(request).await.unwrap(); + perform_ocr_with(host).await.unwrap(); server.await.unwrap(); assert_eq!(seen.lock().unwrap().len(), 2); assert_eq!( - *post_calls.lock().unwrap(), + *responses_received.lock().unwrap(), [ - (1, json!(r#"{"submitted":true}"#)), - (2, json!(r#"{"status":"succeeded"}"#)), + (1, r#"{"submitted":true}"#.to_string()), + (2, r#"{"status":"succeeded"}"#.to_string()), ] ); } @@ -1217,45 +1213,4 @@ mod tests { assert!(error.to_string().contains("dot segment")); } } - - #[tokio::test] - async fn pre_call_guardrail_receives_caller_pages_before_mapping() { - use std::sync::Arc; - - use crate::ocr::hooks::{OcrHookFuture, OcrHooks, OcrPreCallRequest}; - - struct RewritePages; - impl OcrHooks for RewritePages { - fn intercepts_requests(&self) -> bool { - true - } - - fn pre_call(&self, request: OcrPreCallRequest) -> OcrHookFuture<'_, OcrPreCallRequest> { - Box::pin(async move { - assert_eq!(request.optional_params["pages"], json!([0, 2])); - Ok(OcrPreCallRequest { - optional_params: json!({"pages": [1]}), - ..request - }) - }) - } - } - let (base, seen, server) = - mock_server(vec![MockResponse::json(json!({"status": "succeeded"}))]).await; - let request = wire_request( - "azure_ai/doc-intelligence/prebuilt-read", - &base, - json!({"pages": [0, 2]}), - ) - .with_host_hooks(Arc::new(RewritePages), None); - perform_ocr(request).await.unwrap(); - server.await.unwrap(); - let requests = seen.lock().unwrap(); - let target = requests[0].split_whitespace().nth(1).unwrap(); - assert_eq!( - query_value(&format!("{base}{target}"), "pages").as_deref(), - Some("2") - ); - assert_eq!(requests.len(), 1); - } } diff --git a/litellm-rust/crates/core/src/llms/azure_ai/ocr/transformation.rs b/litellm-rust/crates/core/src/llms/azure_ai/ocr/transformation.rs index 99f0b2af07b..c6480ca6dac 100644 --- a/litellm-rust/crates/core/src/llms/azure_ai/ocr/transformation.rs +++ b/litellm-rust/crates/core/src/llms/azure_ai/ocr/transformation.rs @@ -304,12 +304,12 @@ mod tests { ); } - use std::sync::Arc; - use serde_json::json; - use crate::ocr::hooks::{OcrDuringCallRequest, OcrHookFuture, OcrHooks}; - use crate::ocr::test_support::{MockResponse, mock_server, perform_ocr, wire_request}; + use crate::ocr::LocalOcrHost; + use crate::ocr::test_support::{ + MockResponse, mock_server, perform_ocr, perform_ocr_with, wire_request, + }; #[tokio::test] async fn facade_executes_azure_mistral_with_prepared_auth() { @@ -374,82 +374,242 @@ mod tests { ); } - struct ReplaceBodyDocument; - - impl OcrHooks for ReplaceBodyDocument { - fn intercepts_requests(&self) -> bool { - true - } - - fn during_call( - &self, - mut request: OcrDuringCallRequest, - ) -> OcrHookFuture<'_, OcrDuringCallRequest> { - Box::pin(async move { - request.body["document"] = json!({ - "type":"document_url", - "document_url":"https://example.com/not-inline.pdf" - }); - Ok(request) - }) - } - } - #[tokio::test] async fn rejects_non_inline_body_after_guardrails() { - let mut request = wire_request("azure_ai/model", "http://127.0.0.1:1", json!({})); - request.hooks = Arc::new(ReplaceBodyDocument); - let error = perform_ocr(request).await.unwrap_err(); + let request = wire_request("azure_ai/model", "http://127.0.0.1:1", json!({})); + let host = LocalOcrHost::new(request).with_before_send(|mut wire, _| { + wire.body["document"] = json!({ + "type":"document_url", + "document_url":"https://example.com/not-inline.pdf" + }); + Ok(wire) + }); + let error = perform_ocr_with(host).await.unwrap_err(); assert!(error.to_string().contains("data URI")); } - struct EchoCallerDocument(Value); + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; - impl OcrHooks for EchoCallerDocument { - fn intercepts_requests(&self) -> bool { - true + use litellm_auth::{ + ResolvedCredential, SecretValue, TokenFuture, TokenProvider, TokenProviderHandle, + }; + + use crate::ocr::LiteLLMOcrRequest; + use crate::ocr::test_support::header; + use crate::ocr::wire::decode_request; + + #[derive(Debug)] + struct CountingToken { + token: fn(usize) -> String, + calls: AtomicUsize, + } + + impl CountingToken { + fn new(token: fn(usize) -> String) -> Arc { + Arc::new(Self { + token, + calls: AtomicUsize::new(0), + }) } - fn during_call( - &self, - mut request: OcrDuringCallRequest, - ) -> OcrHookFuture<'_, OcrDuringCallRequest> { - let document = self.0.clone(); + fn calls(&self) -> usize { + self.calls.load(Ordering::SeqCst) + } + } + + impl TokenProvider for CountingToken { + fn acquire(&self) -> TokenFuture<'_> { + let call = self.calls.fetch_add(1, Ordering::SeqCst) + 1; + let token = SecretValue::new((self.token)(call)); Box::pin(async move { - request.body["document"] = document; - Ok(request) + Ok(ResolvedCredential::AccessToken { + token, + expires_on: None, + }) }) } } - #[tokio::test] - async fn remote_document_stays_inlined_when_hook_echoes_caller_document() { - let (base, seen, server) = mock_server(vec![ - MockResponse::json(json!("served document")), - MockResponse::json(json!({"pages":[{"index":0,"markdown":"hello"}],"usage_info":{"pages_processed":1}})), - ]) - .await; - let document_url = format!("{base}/document.pdf"); - let mut request = crate::ocr::test_support::with_source( - wire_request("azure_ai/model", &base, json!({})), - &document_url, - ); - request.hooks = Arc::new(EchoCallerDocument( - json!({"type":"document_url","document_url":document_url}), - )); + fn numbered_token(call: usize) -> String { + format!("callback-{call}") + } - let result = perform_ocr(request).await.unwrap(); + fn azure_request( + provider: &Arc, + api_base: Option<&str>, + api_key: Option<&str>, + extra_headers: Value, + optional_params: Value, + ) -> LiteLLMOcrRequest { + let wire = serde_json::from_value(json!({ + "model": "azure_ai/mistral-ocr-latest", + "document": {"type":"document_url","document_url":"data:application/pdf;base64,YWJj"}, + "api_key": api_key, + "api_base": api_base, + "custom_llm_provider": null, + "extra_headers": extra_headers, + "optional_params": optional_params, + "timeout_seconds": 2.0 + })) + .unwrap(); + LiteLLMOcrRequest { + azure_ad_token_provider: Some(TokenProviderHandle::new(provider.clone())), + ..decode_request(wire).unwrap() + } + } + + fn ocr_page() -> MockResponse { + MockResponse::json(json!({"pages":[{"index":0,"markdown":"hello"}]})) + } + + #[tokio::test] + async fn token_provider_result_is_the_bearer_and_is_acquired_for_each_request() { + let provider = CountingToken::new(numbered_token); + let (base, seen, server) = mock_server(vec![ocr_page(), ocr_page()]).await; + + for _ in 0..2 { + perform_ocr(azure_request( + &provider, + Some(&base), + None, + Value::Null, + json!({}), + )) + .await + .unwrap(); + } server.await.unwrap(); - assert_eq!(result.pages[0].markdown, "hello"); + assert_eq!(provider.calls(), 2); let requests = seen.lock().unwrap(); - assert_eq!(requests.len(), 2); - assert!(requests[0].starts_with("GET /document.pdf ")); - let body: Value = - serde_json::from_str(requests[1].split_once("\r\n\r\n").unwrap().1).unwrap(); assert_eq!( - body["document"]["document_url"], - json!("data:application/json;base64,InNlcnZlZCBkb2N1bWVudCI=") + requests + .iter() + .map(|request| header(request, "authorization")) + .collect::>(), + [Some("Bearer callback-1"), Some("Bearer callback-2")] ); } + + #[rstest] + #[case::api_key_skips_provider(Some("resource-key"), Value::Null, json!({}), "Bearer resource-key", 0)] + #[case::provider_beats_static_token( + None, + Value::Null, + json!({"azure_ad_token":"static-token"}), + "Bearer callback-1", + 1 + )] + #[case::header_wins_on_the_wire_but_provider_still_runs( + None, + json!({"Authorization":"Bearer override"}), + json!({}), + "Bearer override", + 1 + )] + #[tokio::test] + async fn credential_precedence( + #[case] api_key: Option<&str>, + #[case] extra_headers: Value, + #[case] optional_params: Value, + #[case] expected_authorization: &str, + #[case] expected_calls: usize, + ) { + let provider = CountingToken::new(numbered_token); + let (base, seen, server) = mock_server(vec![ocr_page()]).await; + + perform_ocr(azure_request( + &provider, + Some(&base), + api_key, + extra_headers, + optional_params, + )) + .await + .unwrap(); + server.await.unwrap(); + + assert_eq!(provider.calls(), expected_calls); + let requests = seen.lock().unwrap(); + assert_eq!(requests.len(), 1); + assert_eq!( + header(&requests[0], "authorization"), + Some(expected_authorization) + ); + } + + #[rstest] + #[case::missing_api_base( + false, + json!({}), + numbered_token, + |error: &crate::ocr::Error| matches!(error, crate::ocr::Error::Auth(litellm_auth::Error::MissingApiBase { + provider: "Azure AI", + environment_variable: AZURE_AI_API_BASE_ENV, + })), + 0 + )] + #[case::unsupported_oidc_reference( + true, + json!({"azure_ad_token":"oidc/assertion","client_id":"client","tenant_id":"tenant"}), + numbered_token, + |error: &crate::ocr::Error| matches!(error, crate::ocr::Error::Auth(litellm_auth::Error::UnsupportedOidcReference)), + 0 + )] + #[case::empty_provider_token_ignores_static_token( + true, + json!({"azure_ad_token":"static-token"}), + |_| String::new(), + |error: &crate::ocr::Error| matches!(error, crate::ocr::Error::MissingAzureAiCredentials), + 1 + )] + #[tokio::test] + async fn credential_failures_send_no_provider_request( + #[case] with_api_base: bool, + #[case] optional_params: Value, + #[case] token: fn(usize) -> String, + #[case] expected: fn(&crate::ocr::Error) -> bool, + #[case] expected_calls: usize, + ) { + let provider = CountingToken::new(token); + let (base, seen, server) = mock_server(vec![ocr_page()]).await; + + let error = perform_ocr(azure_request( + &provider, + with_api_base.then_some(base.as_str()), + None, + Value::Null, + optional_params, + )) + .await + .unwrap_err(); + server.abort(); + + assert!(expected(&error), "unexpected error: {error:?}"); + assert_eq!(provider.calls(), expected_calls); + assert!(seen.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn environment_supplies_api_base_and_bearer_key() { + let env = |name: &str| match name { + AZURE_AI_API_BASE_ENV => Some("https://env.example".to_string()), + AZURE_AI_API_KEY_ENV => Some("env-key".to_string()), + _ => None, + }; + let connection = OcrConnection::default(); + + let headers = AzureAiOcrConfig + .resolve_headers(&connection, &Default::default(), &env) + .await + .unwrap(); + let url = AzureAiOcrConfig.build_ocr_url(None, &env).unwrap(); + + assert_eq!( + headers, + [("Authorization".to_string(), "Bearer env-key".to_string())] + ); + assert_eq!(url, "https://env.example/providers/mistral/azure/ocr"); + } } diff --git a/litellm-rust/crates/core/src/llms/base_llm/ocr/transformation.rs b/litellm-rust/crates/core/src/llms/base_llm/ocr/transformation.rs index 4c4b7a066ef..b9dca3c9bd4 100644 --- a/litellm-rust/crates/core/src/llms/base_llm/ocr/transformation.rs +++ b/litellm-rust/crates/core/src/llms/base_llm/ocr/transformation.rs @@ -1,16 +1,18 @@ use std::future::Future; -use std::sync::Arc; -use serde::Serialize; -use serde::de::DeserializeOwned; +use serde::{Serialize, de::DeserializeOwned}; use serde_json::Value; -use crate::call_arguments::CallArguments; -use crate::ocr::OcrClient; -use crate::ocr::hooks::OcrHooks; -use crate::ocr::types::{ - LiteLLMOcrResponse, OcrConnection, OcrCredentialInputs, OcrDocument, OcrResponseFormat, - PreparedOcrRequest, ResolvedOcrCredentials, +use crate::{ + call_arguments::CallArguments, + ocr::{ + OcrClient, + route::OcrHost, + types::{ + LiteLLMOcrResponse, OcrConnection, OcrCredentialInputs, OcrDocument, OcrResponseFormat, + PreparedOcrRequest, ResolvedOcrCredentials, + }, + }, }; const HEALTH_CHECK_PDF_DATA_URI: &str = "data:application/pdf;base64,JVBERi0xLjQKJeLjz9MKMyAwIG9iago8PC9UeXBlIC9QYWdlCi9QYXJlbnQgMSAwIFIKL01lZGlhQm94IFswIDAgNjEyIDc5Ml0KL0NvbnRlbnRzIDQgMCBSCi9SZXNvdXJjZXMgPDwvRm9udCA8PC9GMSAyIDAgUj4+Pj4+PgplbmRvYmoKNCAwIG9iago8PC9MZW5ndGggNDQ+PgpzdHJlYW0KQlQKL0YxIDI0IFRmCjEwMCA3MDAgVGQKKHRlc3QpIFRqCkVUCmVuZHN0cmVhbQplbmRvYmoKMiAwIG9iago8PC9UeXBlIC9Gb250Ci9TdWJ0eXBlIC9UeXBlMQovQmFzZUZvbnQgL0hlbHZldGljYT4+CmVuZG9iagoxIDAgb2JqCjw8L1R5cGUgL1BhZ2VzCi9LaWRzIFszIDAgUl0KL0NvdW50IDE+PgplbmRvYmoKNSAwIG9iago8PC9UeXBlIC9DYXRhbG9nCi9QYWdlcyAxIDAgUj4+CmVuZG9iagp0cmFpbGVyCjw8L1NpemUgNgovUm9vdCA1IDAgUj4+CnN0YXJ0eHJlZgozMjQKJSVFT0Y="; @@ -37,7 +39,7 @@ pub(crate) struct OcrRequestContext<'a> { pub(crate) struct OcrResponseContext<'a> { pub client: &'a OcrClient, pub connection: &'a OcrConnection, - pub hooks: &'a Arc, + pub host: &'a OcrHost, pub request_format: OcrResponseFormat, pub url: &'a str, pub headers: &'a [(String, String)], @@ -133,7 +135,7 @@ pub(crate) trait BaseOcrConfig: Send + Sync + Sized + 'static { context.connection.max_response_bytes, ) .await?; - crate::ocr::handler::post_call(context.hooks, &bytes).await?; + crate::ocr::handler::emit_response_received(context.host, &bytes).await?; self.transform_ocr_response(model, &bytes, context.request_format) } } diff --git a/litellm-rust/crates/core/src/llms/cohere/ocr/transformation.rs b/litellm-rust/crates/core/src/llms/cohere/ocr/transformation.rs index 925e20c8947..573c0b833d8 100644 --- a/litellm-rust/crates/core/src/llms/cohere/ocr/transformation.rs +++ b/litellm-rust/crates/core/src/llms/cohere/ocr/transformation.rs @@ -2,18 +2,22 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use serde_with::serde_as; -use crate::call_arguments::{CallArguments, parse_options}; -use crate::constants::{COHERE_API_KEY_ENV, COHERE_PARSE_API_BASE}; -use crate::llms::base_llm::ocr::transformation::{BaseOcrConfig, decode_and_normalize_response}; -use crate::ocr::OcrClient; -use crate::ocr::document::InlineDocument; -use crate::ocr::prepare::credential_env; -use crate::ocr::types::{ - LiteLLMOcrResponse, OcrConnection, OcrDocument, OcrPage, OcrPageImage, OcrResponseFormat, - OcrUsageInfo, PreparedOcrRequest, +use crate::{ + call_arguments::{CallArguments, parse_options}, + constants::{COHERE_API_KEY_ENV, COHERE_PARSE_API_BASE}, + llms::base_llm::ocr::transformation::{BaseOcrConfig, decode_and_normalize_response}, + ocr::{ + OcrClient, + document::InlineDocument, + prepare::credential_env, + types::{ + LiteLLMOcrResponse, OcrConnection, OcrDocument, OcrPage, OcrPageImage, + OcrResponseFormat, OcrUsageInfo, PreparedOcrRequest, + }, + }, + serde_compat::LaxI64, + url_utils::ApiUrl, }; -use crate::serde_compat::LaxI64; -use crate::url_utils::ApiUrl; const COHERE_PARSE_HEALTH_CHECK_IMAGE_DATA_URI: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4//8/AAX+Av4N70a4AAAAAElFTkSuQmCC"; @@ -339,7 +343,7 @@ mod tests { "cohere/parse", "https://example.com", json!({ - "output_format":"markdown", "metadata":{"host":true}, + "output_format":"markdown", "timeout":30, "extra_body":{ "output_format": {"future":true}, "document":{"type":"image_url","image_url":"https://example.com/a.png", @@ -353,7 +357,7 @@ mod tests { })) .unwrap(), ); - let request = crate::ocr::prepare::prepare_request(request); + let request = crate::ocr::prepare::prepare_request_for_test(request); let http = CohereParseConfig .prepare_request(&request, &crate::ocr::test_support::ocr_client()) .await @@ -512,7 +516,7 @@ mod tests { request.response_format().unwrap(), crate::ocr::types::OcrResponseFormat::Litellm ); - let request = crate::ocr::prepare::prepare_request(request); + let request = crate::ocr::prepare::prepare_request_for_test(request); let http = CohereParseConfig .prepare_request(&request, &crate::ocr::test_support::ocr_client()) .await @@ -747,15 +751,19 @@ mod tests { } #[rstest] - #[case::base("")] - #[case::version("/v2")] - #[case::complete("/v2/parse")] - fn completes_provider_urls_without_duplicate_paths_and_preserves_queries(#[case] suffix: &str) { + #[case::base("", "/v2/parse")] + #[case::version("/v2", "/v2/parse")] + #[case::complete("/v2/parse", "/v2/parse")] + #[case::proxy_prefix("/cohere/", "/cohere/v2/parse")] + fn completes_provider_urls_without_duplicate_paths_and_preserves_queries( + #[case] suffix: &str, + #[case] path: &str, + ) { assert_eq!( CohereParseConfig .build_ocr_url(&format!("https://example.com{suffix}?tenant=a")) .unwrap(), - "https://example.com/v2/parse?tenant=a" + format!("https://example.com{path}?tenant=a") ); } @@ -779,4 +787,84 @@ mod tests { Err(crate::ocr::Error::Auth(_)) )); } + + #[test] + fn environment_key_becomes_the_bearer() { + let headers = CohereParseConfig + .resolve_headers(&OcrConnection::default(), &|name| { + (name == COHERE_API_KEY_ENV).then(|| "env-key".to_string()) + }) + .unwrap(); + + assert_eq!( + headers, + [("Authorization".to_string(), "Bearer env-key".to_string())] + ); + } + + #[test] + fn missing_key_names_the_environment_variable() { + let error = CohereParseConfig + .resolve_headers(&OcrConnection::default(), &|_| None) + .unwrap_err(); + + assert!(error.to_string().contains(COHERE_API_KEY_ENV), "{error}"); + } + + #[rstest] + #[case::cohere("cohere/parse-v5.0", "POST /v2/parse ")] + #[case::azure_ai("azure_ai/Cohere-parse-v5.0", "POST /providers/cohere/v2/parse ")] + #[tokio::test] + async fn route_sends_image_to_its_parse_endpoint_with_the_bearer_key( + #[case] model: &str, + #[case] request_line: &str, + ) { + use crate::ocr::test_support::{MockResponse, header, mock_server, perform_ocr}; + + let (base, seen, server) = mock_server(vec![MockResponse::json(json!({"pages":[]}))]).await; + let request = crate::ocr::test_support::wire_request(model, &base, json!({})) + .with_document( + serde_json::from_value::( + json!({"type":"image_url","image_url":"data:image/png;base64,YWJj"}), + ) + .unwrap() + .into(), + ); + + perform_ocr(request).await.unwrap(); + server.await.unwrap(); + + let requests = seen.lock().unwrap(); + assert_eq!(requests.len(), 1); + assert!(requests[0].starts_with(request_line), "{}", requests[0]); + assert_eq!( + header(&requests[0], "authorization"), + Some("Bearer test-key") + ); + } + + #[rstest] + #[tokio::test] + async fn route_rejects_non_image_document_without_a_request( + #[values("cohere/parse-v5.0", "azure_ai/Cohere-parse-v5.0")] model: &str, + ) { + use crate::ocr::test_support::{MockResponse, mock_server, perform_ocr}; + + let (base, seen, server) = mock_server(vec![MockResponse::json(json!({"pages":[]}))]).await; + + let error = perform_ocr(crate::ocr::test_support::wire_request( + model, + &base, + json!({}), + )) + .await + .unwrap_err(); + server.abort(); + + assert!( + matches!(error, crate::ocr::Error::CohereImageOnly), + "{error:?}" + ); + assert!(seen.lock().unwrap().is_empty()); + } } diff --git a/litellm-rust/crates/core/src/llms/mistral/ocr/transformation.rs b/litellm-rust/crates/core/src/llms/mistral/ocr/transformation.rs index 71dcf88cd0f..dac1ed7c68f 100644 --- a/litellm-rust/crates/core/src/llms/mistral/ocr/transformation.rs +++ b/litellm-rust/crates/core/src/llms/mistral/ocr/transformation.rs @@ -1,17 +1,21 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; -use crate::call_arguments::CallArguments; -use crate::constants::MISTRAL_OCR_API_BASE; -use crate::llms::base_llm::ocr::transformation::{BaseOcrConfig, decode_and_normalize_response}; -use crate::ocr::OcrClient; -use crate::ocr::prepare::credential_env; -use crate::ocr::types::{ - LiteLLMOcrResponse, OcrConnection, OcrDocument, OcrPage, OcrResponseFormat, OcrUsageInfo, - PreparedOcrRequest, +use crate::{ + call_arguments::CallArguments, + constants::MISTRAL_OCR_API_BASE, + llms::base_llm::ocr::transformation::{BaseOcrConfig, decode_and_normalize_response}, + ocr::{ + OcrClient, + prepare::credential_env, + types::{ + LiteLLMOcrResponse, OcrConnection, OcrDocument, OcrPage, OcrResponseFormat, + OcrUsageInfo, PreparedOcrRequest, + }, + }, + params::OpaqueParams, + url_utils::ApiUrl, }; -use crate::params::OpaqueParams; -use crate::url_utils::ApiUrl; const MISTRAL_OCR_API_KEY_ENV_VAR: &str = "MISTRAL_API_KEY"; @@ -618,6 +622,22 @@ mod tests { ); } + #[rstest] + fn environment_keeps_extra_headers_after_the_bearer_key( + #[with(Some("explicit"), vec![("X-Trace".into(), "trace-1".into())])] + connection: OcrConnection, + ) { + assert_eq!( + MistralOcrConfig + .resolve_headers(&connection, &|_| None) + .unwrap(), + [ + ("Authorization".to_string(), "Bearer explicit".to_string()), + ("X-Trace".to_string(), "trace-1".to_string()), + ] + ); + } + #[rstest] fn environment_rejects_missing_key(connection: OcrConnection) { assert!(matches!( diff --git a/litellm-rust/crates/core/src/llms/openai/responses/transformation.rs b/litellm-rust/crates/core/src/llms/openai/responses/transformation.rs index 220933d3db0..2c8916b6806 100644 --- a/litellm-rust/crates/core/src/llms/openai/responses/transformation.rs +++ b/litellm-rust/crates/core/src/llms/openai/responses/transformation.rs @@ -1,6 +1,8 @@ -use crate::responses::Error; -use crate::responses::types::{ResponsesWsEvent, ResponsesWsTransformResult}; -use crate::responses::websocket::{ResponsesWebSocketProviderConfig, enforce_model}; +use crate::responses::{ + Error, + types::{ResponsesWsEvent, ResponsesWsTransformResult}, + websocket::{ResponsesWebSocketProviderConfig, enforce_model}, +}; pub struct OpenAiResponsesApiConfig; diff --git a/litellm-rust/crates/core/src/llms/reducto/ocr/transformation.rs b/litellm-rust/crates/core/src/llms/reducto/ocr/transformation.rs index 98f981a239d..4c5323ef50e 100644 --- a/litellm-rust/crates/core/src/llms/reducto/ocr/transformation.rs +++ b/litellm-rust/crates/core/src/llms/reducto/ocr/transformation.rs @@ -3,20 +3,24 @@ use std::collections::BTreeMap; use serde::{Deserialize, Deserializer, Serialize}; use serde_json::{Map, Value, json}; -use crate::call_arguments::{CallArguments, compose_body}; -use crate::constants::{REDUCTO_API_BASE, REDUCTO_API_KEY_ENV, REDUCTO_ID_PREFIX}; -use crate::llms::base_llm::ocr::transformation::{ - BaseOcrConfig, OcrRequestContext, decode_and_normalize_response, +use crate::{ + call_arguments::{CallArguments, compose_body}, + constants::{REDUCTO_API_BASE, REDUCTO_API_KEY_ENV, REDUCTO_ID_PREFIX}, + llms::base_llm::ocr::transformation::{ + BaseOcrConfig, OcrRequestContext, decode_and_normalize_response, + }, + ocr::{ + OcrClient, + document::InlineDocument, + prepare::{build_http_request, credential_env, guardrail_document}, + types::{ + LiteLLMOcrResponse, OcrConnection, OcrDocument, OcrPage, OcrResponseFormat, + OcrUsageInfo, PreparedOcrRequest, + }, + }, + params::OpaqueParams, + url_utils::ApiUrl, }; -use crate::ocr::OcrClient; -use crate::ocr::document::InlineDocument; -use crate::ocr::prepare::{build_http_request, credential_env, guardrail_document}; -use crate::ocr::types::{ - LiteLLMOcrResponse, OcrConnection, OcrDocument, OcrPage, OcrResponseFormat, OcrUsageInfo, - PreparedOcrRequest, -}; -use crate::params::OpaqueParams; -use crate::url_utils::ApiUrl; #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(transparent)] @@ -682,12 +686,13 @@ mod tests { ); } - use std::sync::Arc; - + use litellm_callbacks::event::{CallEvent, WireRequest}; use rstest::rstest; - use crate::ocr::hooks::{OcrDuringCallRequest, OcrHookFuture, OcrHooks, OcrPostCallRequest}; - use crate::ocr::test_support::{MockResponse, mock_server, perform_ocr, wire_request}; + use crate::ocr::{ + LocalOcrHost, + test_support::{MockResponse, mock_server, perform_ocr, perform_ocr_with, wire_request}, + }; fn request_body(request: &str) -> Value { serde_json::from_str(request.split_once("\r\n\r\n").unwrap().1).unwrap() @@ -783,38 +788,23 @@ mod tests { assert!(requests[1].starts_with("POST /parse ")); } - struct ParseBoundary { - request_count: Arc>>, - } - - impl OcrHooks for ParseBoundary { - fn post_call(&self, request: OcrPostCallRequest) -> OcrHookFuture<'_, OcrPostCallRequest> { - Box::pin(async move { - assert_eq!(self.request_count.lock().unwrap().len(), 2); - assert_eq!( - request.original_response, - json!(r#"{"result":{"chunks":[]}}"#) - ); - Ok(request) - }) - } - } - #[tokio::test] - async fn post_call_stays_after_reducto_upload_and_parse() { + async fn response_received_stays_after_reducto_upload_and_parse() { let (base, seen, server) = mock_server(vec![ MockResponse::json(json!({"file_id":"reducto://uploaded.pdf"})), MockResponse::json(json!({"result":{"chunks":[]}})), ]) .await; - let request = crate::ocr::LiteLLMOcrRequest { - hooks: Arc::new(ParseBoundary { - request_count: seen.clone(), - }), - ..wire_request("reducto/parse-v3", &base, json!({})) - }; + let request_count = seen.clone(); + let host = LocalOcrHost::new(wire_request("reducto/parse-v3", &base, json!({}))) + .with_observer(move |event| { + if let CallEvent::ResponseReceived { raw } = event { + assert_eq!(request_count.lock().unwrap().len(), 2); + assert_eq!(raw.body, r#"{"result":{"chunks":[]}}"#); + } + }); - perform_ocr(request).await.unwrap(); + perform_ocr_with(host).await.unwrap(); server.await.unwrap(); assert_eq!(seen.lock().unwrap().len(), 2); } @@ -932,28 +922,6 @@ mod tests { ); } - struct RewriteDocument; - - struct RewriteHeaders; - - impl OcrHooks for RewriteHeaders { - fn intercepts_requests(&self) -> bool { - true - } - - fn during_call( - &self, - request: OcrDuringCallRequest, - ) -> OcrHookFuture<'_, OcrDuringCallRequest> { - Box::pin(async move { - Ok(OcrDuringCallRequest { - headers: vec![("authorization".into(), "Bearer guarded".into())], - ..request - }) - }) - } - } - #[rstest] #[case("reducto/parse-v3")] #[case("reducto/parse-legacy")] @@ -966,9 +934,14 @@ mod tests { .await; let mut request = wire_request(model, &base, json!({})); request.transport.extra_headers = vec![("authorization".into(), "Bearer original".into())]; - request.hooks = Arc::new(RewriteHeaders); + let host = LocalOcrHost::new(request).with_before_send(|wire, _| { + Ok(WireRequest { + headers: vec![("authorization".into(), "Bearer guarded".into())], + ..wire + }) + }); - perform_ocr(request).await.unwrap(); + perform_ocr_with(host).await.unwrap(); server.await.unwrap(); let requests = seen.lock().unwrap(); assert_eq!(requests.len(), 2); @@ -980,36 +953,23 @@ mod tests { } } - impl OcrHooks for RewriteDocument { - fn intercepts_requests(&self) -> bool { - true - } - - fn during_call( - &self, - request: OcrDuringCallRequest, - ) -> OcrHookFuture<'_, OcrDuringCallRequest> { - Box::pin(async move { - assert_eq!( - request.body["document_url"], - "data:application/pdf;base64,YWJj" - ); - Ok(OcrDuringCallRequest { - body: json!({"type":"document_url","document_url":"reducto://guarded.pdf"}), - ..request - }) - }) - } - } - #[tokio::test] async fn guardrail_rewrites_document_before_upload() { let (base, seen, server) = mock_server(vec![MockResponse::json(json!({"result":{"chunks":[]}}))]).await; - let mut request = wire_request("reducto/parse-v3", &base, json!({})); - request.hooks = Arc::new(RewriteDocument); + let host = LocalOcrHost::new(wire_request("reducto/parse-v3", &base, json!({}))) + .with_before_send(|wire, _| { + assert_eq!( + wire.body["document_url"], + "data:application/pdf;base64,YWJj" + ); + Ok(WireRequest { + body: json!({"type":"document_url","document_url":"reducto://guarded.pdf"}), + ..wire + }) + }); - perform_ocr(request).await.unwrap(); + perform_ocr_with(host).await.unwrap(); server.await.unwrap(); let requests = seen.lock().unwrap(); assert_eq!(requests.len(), 1); diff --git a/litellm-rust/crates/core/src/llms/vertex_ai/ocr/deepseek_transformation.rs b/litellm-rust/crates/core/src/llms/vertex_ai/ocr/deepseek_transformation.rs index ffa0fd28202..6aece071d26 100644 --- a/litellm-rust/crates/core/src/llms/vertex_ai/ocr/deepseek_transformation.rs +++ b/litellm-rust/crates/core/src/llms/vertex_ai/ocr/deepseek_transformation.rs @@ -3,16 +3,20 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use super::transformation::VertexAiOcrConfig; -use crate::call_arguments::CallArguments; -use crate::llms::base_llm::ocr::transformation::{BaseOcrConfig, OcrRequestContext}; -use crate::ocr::OcrClient; -use crate::ocr::prepare::credential_env; -use crate::ocr::types::{ - LiteLLMOcrResponse, OcrDocument, OcrPage, OcrPageDimensions, OcrPageImage, OcrUsageInfo, - PreparedOcrRequest, +use crate::{ + call_arguments::CallArguments, + llms::base_llm::ocr::transformation::{BaseOcrConfig, OcrRequestContext}, + ocr::{ + OcrClient, + prepare::credential_env, + types::{ + LiteLLMOcrResponse, OcrDocument, OcrPage, OcrPageDimensions, OcrPageImage, + OcrUsageInfo, PreparedOcrRequest, + }, + }, + params::OpaqueParams, + url_utils::ApiUrl, }; -use crate::params::OpaqueParams; -use crate::url_utils::ApiUrl; const DEFAULT_API_BASE: &str = "https://aiplatform.googleapis.com"; const MODEL_PREFIX: &str = "deepseek-ai/"; @@ -456,8 +460,7 @@ mod tests { use rstest::rstest; - use crate::llms::base_llm::ocr::transformation::BaseOcrConfig; - use crate::ocr::types::OcrDocument; + use crate::{llms::base_llm::ocr::transformation::BaseOcrConfig, ocr::types::OcrDocument}; fn document() -> OcrDocument { serde_json::from_value(json!({"type":"image_url","image_url":"gs://bucket/a.png"})).unwrap() diff --git a/litellm-rust/crates/core/src/llms/vertex_ai/ocr/transformation.rs b/litellm-rust/crates/core/src/llms/vertex_ai/ocr/transformation.rs index 28c2b8a09da..bd7c5da7632 100644 --- a/litellm-rust/crates/core/src/llms/vertex_ai/ocr/transformation.rs +++ b/litellm-rust/crates/core/src/llms/vertex_ai/ocr/transformation.rs @@ -2,17 +2,21 @@ use litellm_auth_gcp::{self as vertex, VertexConfig}; use serde_json::Value; use super::common_utils::validate_destination; -use crate::call_arguments::CallArguments; -use crate::llms::base_llm::ocr::transformation::{ - BaseOcrConfig, OcrEnvironment, OcrRequestContext, +use crate::{ + call_arguments::CallArguments, + llms::{ + base_llm::ocr::transformation::{BaseOcrConfig, OcrEnvironment, OcrRequestContext}, + mistral::ocr::transformation::{MistralOcrConfig, MistralOcrRequest}, + }, + ocr::{ + OcrClient, + document::{inline_remote_document, validate_inline_document}, + prepare::credential_env, + types::{LiteLLMOcrResponse, OcrConnection, OcrDocument, PreparedOcrRequest}, + }, + params::OpaqueParams, + url_utils::ApiUrl, }; -use crate::llms::mistral::ocr::transformation::{MistralOcrConfig, MistralOcrRequest}; -use crate::ocr::OcrClient; -use crate::ocr::document::{inline_remote_document, validate_inline_document}; -use crate::ocr::prepare::credential_env; -use crate::ocr::types::{LiteLLMOcrResponse, OcrConnection, OcrDocument, PreparedOcrRequest}; -use crate::params::OpaqueParams; -use crate::url_utils::ApiUrl; const DEFAULT_LOCATION: &str = "us-central1"; @@ -198,9 +202,10 @@ fn validate_location(location: &str) -> Result<(), crate::ocr::Error> { #[cfg(test)] mod tests { - use super::VertexAiOcrConfig; use rstest::rstest; + use super::VertexAiOcrConfig; + #[test] fn endpoint_uses_location_project_and_model() { assert_eq!( @@ -329,10 +334,14 @@ mod tests { ) { use std::time::Duration; - use crate::llms::base_llm::ocr::transformation::BaseOcrConfig; - use crate::llms::mistral::ocr::transformation::MistralOcrConfig; - use crate::llms::vertex_ai::ocr::transformation::VertexAiOcrConfig; - use crate::ocr::test_support::ocr_client; + use crate::{ + llms::{ + base_llm::ocr::transformation::BaseOcrConfig, + mistral::ocr::transformation::MistralOcrConfig, + vertex_ai::ocr::transformation::VertexAiOcrConfig, + }, + ocr::test_support::ocr_client, + }; let client = ocr_client(); let options = json!({ @@ -348,10 +357,10 @@ mod tests { options.clone(), ); let vertex = wire_request("vertex_ai/mistral-ocr-maas", "https://vertex.test", options); - let direct = crate::ocr::prepare::prepare_request( + let direct = crate::ocr::prepare::prepare_request_for_test( crate::ocr::test_support::resolved_request(direct), ); - let vertex = crate::ocr::prepare::prepare_request( + let vertex = crate::ocr::prepare::prepare_request_for_test( crate::ocr::test_support::resolved_request(vertex), ); let direct_http = MistralOcrConfig diff --git a/litellm-rust/crates/core/src/machine/auth.rs b/litellm-rust/crates/core/src/machine/auth.rs new file mode 100644 index 00000000000..6a3e4daf6ee --- /dev/null +++ b/litellm-rust/crates/core/src/machine/auth.rs @@ -0,0 +1,53 @@ +use std::sync::Arc; + +use litellm_auth::{Error, ResolvedCredential, TokenFuture, TokenProvider, TokenProviderHandle}; +use litellm_callbacks::route::Route; + +use super::{HostChannel, MachineFault}; + +/// A route whose host can mint credentials on the call's behalf. +pub trait TokenRoute: Route { + fn acquire_token_op() -> Self::Op; + fn token_credential(result: Self::OpResult) -> Option; +} + +/// A [`TokenProvider`] that asks the host for each credential through the call's own +/// operation channel, so the host answers it on the caller's thread and context. +pub struct HostTokenProvider { + channel: HostChannel, +} + +impl std::fmt::Debug for HostTokenProvider { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("HostTokenProvider") + } +} + +impl HostTokenProvider +where + R: TokenRoute, + R::Error: From + std::fmt::Display, +{ + pub fn handle(channel: HostChannel) -> TokenProviderHandle { + TokenProviderHandle::new(Arc::new(Self { channel })) + } +} + +impl TokenProvider for HostTokenProvider +where + R: TokenRoute, + R::Error: From + std::fmt::Display, +{ + fn acquire(&self) -> TokenFuture<'_> { + Box::pin(async move { + let result = self + .channel + .route(R::acquire_token_op()) + .await + .map_err(|error| Error::AzureTokenAcquisition(error.to_string()))?; + R::token_credential(result).ok_or_else(|| { + Error::AzureTokenAcquisition("invalid token provider host result".into()) + }) + }) + } +} diff --git a/litellm-rust/crates/core/src/machine/mod.rs b/litellm-rust/crates/core/src/machine/mod.rs new file mode 100644 index 00000000000..f4ca3e407e8 --- /dev/null +++ b/litellm-rust/crates/core/src/machine/mod.rs @@ -0,0 +1,202 @@ +//! The one machine every route runs on: it owns the route's provider future, polls it in +//! place, and turns the host operations that future requests into [`Machine`] steps. No +//! task is spawned; dropping the machine drops the in-flight call. + +mod auth; + +use std::{future::Future, pin::Pin}; + +pub use auth::{HostTokenProvider, TokenRoute}; +use litellm_callbacks::{ + event::{CallEvent, RequestContext, WireRequest}, + host::{HostOp, HostResult}, + machine::{HostFailure, Interrupted, Machine, MachineStep, Step}, + route::Route, +}; +use tokio::sync::{mpsc, oneshot}; + +/// The machine's own failures, distinct from anything the provider call reports. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MachineFault { + /// The host driver went away while the call was waiting on it. + Abandoned, + /// The host answered out of turn: a result with nothing pending, or nothing when a + /// result was pending. + Protocol(&'static str), + /// The host answered a route operation with the wrong result variant. + Mismatch, +} + +pub type ExecuteFuture = + Pin::Response, ::Error>> + Send>>; + +struct PendingOp { + op: HostOp, + reply: oneshot::Sender>, +} + +/// The provider side of the machine: how the in-flight call reaches its host. +pub struct HostChannel { + ops: Option>>, +} + +impl Clone for HostChannel { + fn clone(&self) -> Self { + Self { + ops: self.ops.clone(), + } + } +} + +impl HostChannel { + /// A channel with no host behind it: the wire request goes out unchanged, events go + /// nowhere, and route operations fail. For tests that prepare a request without + /// driving it. + #[cfg(test)] + pub(crate) fn detached() -> Self { + Self { ops: None } + } +} + +impl HostChannel +where + R::Error: From, +{ + async fn invoke(&self, op: HostOp) -> Result, R::Error> { + let ops = self.ops.as_ref().ok_or(MachineFault::Abandoned)?; + let (reply, answer) = oneshot::channel(); + ops.send(PendingOp { op, reply }) + .map_err(|_| MachineFault::Abandoned)?; + answer.await.map_err(|_| MachineFault::Abandoned.into()) + } + + pub async fn route(&self, op: R::Op) -> Result { + match self.invoke(HostOp::Route(op)).await? { + HostResult::Route(result) => Ok(result), + _ => Err(MachineFault::Mismatch.into()), + } + } + + pub async fn before_send( + &self, + wire: WireRequest, + context: RequestContext, + ) -> Result { + if self.ops.is_none() { + return Ok(wire); + } + let op = HostOp::BeforeSend { + wire: Box::new(wire), + context: Box::new(context), + }; + match self.invoke(op).await? { + HostResult::BeforeSend(wire) => Ok(*wire), + _ => Err(MachineFault::Mismatch.into()), + } + } + + pub async fn emit(&self, event: CallEvent) -> Result<(), R::Error> { + if self.ops.is_none() { + return Ok(()); + } + match self.invoke(HostOp::Emit(event)).await? { + HostResult::Emitted => Ok(()), + _ => Err(MachineFault::Mismatch.into()), + } + } +} + +enum Execution { + Unstarted(Box) -> ExecuteFuture + Send>), + Running(ExecuteFuture), + Done, +} + +pub struct RouteMachine { + execution: Execution, + ops: mpsc::UnboundedReceiver>, + channel: HostChannel, + reply: Option>>, +} + +impl RouteMachine +where + R::Error: From, +{ + pub fn new(execute: impl FnOnce(HostChannel) -> ExecuteFuture + Send + 'static) -> Self { + let (ops_tx, ops) = mpsc::unbounded_channel(); + Self { + execution: Execution::Unstarted(Box::new(execute)), + ops, + channel: HostChannel { ops: Some(ops_tx) }, + reply: None, + } + } + + async fn step( + &mut self, + result: Option>, + ) -> Result, R::Error> { + match (self.reply.take(), result) { + (Some(reply), Some(result)) => { + reply + .send(result) + .map_err(|_| MachineFault::Protocol("the call stopped waiting on the host"))?; + } + (None, None) if matches!(self.execution, Execution::Unstarted(_)) => {} + (Some(reply), None) => { + self.reply = Some(reply); + return Err(MachineFault::Protocol("host operation result is required").into()); + } + (None, Some(_)) => { + return Err(MachineFault::Protocol("unexpected host operation result").into()); + } + (None, None) => { + return Err( + MachineFault::Protocol("call cannot be resumed after completion").into(), + ); + } + } + if let Execution::Unstarted(_) = self.execution { + let Execution::Unstarted(start) = + std::mem::replace(&mut self.execution, Execution::Done) + else { + unreachable!() + }; + self.execution = Execution::Running(start(self.channel.clone())); + } + let Execution::Running(future) = &mut self.execution else { + return Err(MachineFault::Protocol("call cannot be resumed after completion").into()); + }; + tokio::select! { + biased; + pending = self.ops.recv() => { + let pending = pending.ok_or(MachineFault::Abandoned)?; + self.reply = Some(pending.reply); + Ok(MachineStep::Host(pending.op)) + } + outcome = future => { + self.execution = Execution::Done; + outcome.map(MachineStep::Complete) + } + } + } +} + +impl Machine for RouteMachine +where + R::Error: From, +{ + type Route = R; + type Complete = R::Response; + + fn resume(&mut self, result: Option>) -> Step<'_, Self> { + Box::pin(self.step(result)) + } + + fn interrupt(&mut self, failure: HostFailure) -> Interrupted<'_, Self> { + self.reply = None; + self.execution = Execution::Done; + Box::pin(async move { Err(failure.into_error()) }) + } +} diff --git a/litellm-rust/crates/core/src/media.rs b/litellm-rust/crates/core/src/media.rs index 0b5bc7f575d..3a6579bb0a6 100644 --- a/litellm-rust/crates/core/src/media.rs +++ b/litellm-rust/crates/core/src/media.rs @@ -1,12 +1,16 @@ -use std::future::Future; -use std::io; -use std::net::{IpAddr, SocketAddr}; -use std::pin::Pin; -use std::sync::Arc; -use std::time::Duration; +use std::{ + future::Future, + io, + net::{IpAddr, SocketAddr}, + pin::Pin, + sync::Arc, + time::Duration, +}; -use reqwest::Url; -use reqwest::dns::{Addrs, Name, Resolve, Resolving}; +use reqwest::{ + Url, + dns::{Addrs, Name, Resolve, Resolving}, +}; use crate::constants::MEDIA_CONNECT_TIMEOUT_SECS; @@ -281,8 +285,10 @@ impl Resolve for PublicDnsResolver { mod tests { use std::collections::HashSet; - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - use tokio::net::TcpListener; + use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, + }; use super::*; diff --git a/litellm-rust/crates/core/src/messages/client.rs b/litellm-rust/crates/core/src/messages/client.rs index 6281270b964..ca70b1b03eb 100644 --- a/litellm-rust/crates/core/src/messages/client.rs +++ b/litellm-rust/crates/core/src/messages/client.rs @@ -1,5 +1,4 @@ -use std::sync::OnceLock; -use std::time::Duration; +use std::{sync::OnceLock, time::Duration}; use crate::constants::{MESSAGES_CONNECT_TIMEOUT_SECS, MESSAGES_TIMEOUT_SECS}; diff --git a/litellm-rust/crates/core/src/messages/common_utils.rs b/litellm-rust/crates/core/src/messages/common_utils.rs index c58e9122cad..81d67520abe 100644 --- a/litellm-rust/crates/core/src/messages/common_utils.rs +++ b/litellm-rust/crates/core/src/messages/common_utils.rs @@ -1,11 +1,13 @@ +use litellm_providers::{ + anthropic::experimental_pass_through::messages::transformation::ANTHROPIC_MESSAGES_CONFIG, + azure_ai::anthropic::messages_transformation::AZURE_ANTHROPIC_MESSAGES_CONFIG, + base_llm::anthropic_messages::transformation::BaseAnthropicMessagesConfig, +}; use serde_json::{Map, Value}; use super::Error; use crate::http_utils::string_headers as shared_string_headers; pub(super) use crate::http_utils::{has_bearer_auth, has_header, truncate_error_body}; -use litellm_providers::anthropic::experimental_pass_through::messages::transformation::ANTHROPIC_MESSAGES_CONFIG; -use litellm_providers::azure_ai::anthropic::messages_transformation::AZURE_ANTHROPIC_MESSAGES_CONFIG; -use litellm_providers::base_llm::anthropic_messages::transformation::BaseAnthropicMessagesConfig; const HEADER_CONTEXT: &str = "messages"; diff --git a/litellm-rust/crates/core/src/messages/handler.rs b/litellm-rust/crates/core/src/messages/handler.rs index e241bc56c1e..ff3ae5765ff 100644 --- a/litellm-rust/crates/core/src/messages/handler.rs +++ b/litellm-rust/crates/core/src/messages/handler.rs @@ -1,10 +1,11 @@ -use super::Error; -use super::client::http_client; -use super::common_utils::truncate_error_body; -use super::prepare::prepare_provider_request; -use super::types::{AnthropicMessagesResponse, MessagesRequest}; -use crate::constants::ANTHROPIC_MESSAGES_PROVIDER; -use crate::http_utils::http_request; +use super::{ + Error, + client::http_client, + common_utils::truncate_error_body, + prepare::prepare_provider_request, + types::{AnthropicMessagesResponse, MessagesRequest}, +}; +use crate::{constants::ANTHROPIC_MESSAGES_PROVIDER, http_utils::http_request}; pub(super) async fn execute_messages_provider_call( request: MessagesRequest<'_>, diff --git a/litellm-rust/crates/core/src/messages/mod.rs b/litellm-rust/crates/core/src/messages/mod.rs index 5149c52478d..812094f637c 100644 --- a/litellm-rust/crates/core/src/messages/mod.rs +++ b/litellm-rust/crates/core/src/messages/mod.rs @@ -13,9 +13,8 @@ mod client; mod common_utils; mod handler; mod prepare; -pub use litellm_providers::messages::types; - use handler::{execute_messages_provider_call, execute_messages_provider_stream}; +pub use litellm_providers::messages::types; use types::{AnthropicMessagesResponse, MessagesRequest}; pub async fn messages(request: MessagesRequest<'_>) -> Result { diff --git a/litellm-rust/crates/core/src/messages/prepare.rs b/litellm-rust/crates/core/src/messages/prepare.rs index f3735ff1700..4a6c871172f 100644 --- a/litellm-rust/crates/core/src/messages/prepare.rs +++ b/litellm-rust/crates/core/src/messages/prepare.rs @@ -1,14 +1,16 @@ -use serde_json::{Map, Value}; - -use super::Error; -use super::common_utils::{has_bearer_auth, has_header, messages_provider_config, string_headers}; -use super::types::{MessagesRequest, ProviderMessagesRequest}; -use crate::litellm_core_utils::get_llm_provider_logic::{ - CustomLlmProvider, get_custom_llm_provider, -}; use litellm_providers::base_llm::anthropic_messages::transformation::{ BaseAnthropicMessagesConfig, MessagesAuthStrategy, }; +use serde_json::{Map, Value}; + +use super::{ + Error, + common_utils::{has_bearer_auth, has_header, messages_provider_config, string_headers}, + types::{MessagesRequest, ProviderMessagesRequest}, +}; +use crate::litellm_core_utils::get_llm_provider_logic::{ + CustomLlmProvider, get_custom_llm_provider, +}; pub(super) fn prepare_provider_request( request: MessagesRequest<'_>, diff --git a/litellm-rust/crates/core/src/messages/tests.rs b/litellm-rust/crates/core/src/messages/tests.rs index 212096fbd53..98b9bd626a9 100644 --- a/litellm-rust/crates/core/src/messages/tests.rs +++ b/litellm-rust/crates/core/src/messages/tests.rs @@ -1,15 +1,19 @@ use std::time::Duration; use serde_json::{Map, Value, json}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::{TcpListener, TcpStream}; - -use super::Error; -use super::common_utils::{ - has_bearer_auth, has_header, messages_provider_config, string_headers, truncate_error_body, +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::{TcpListener, TcpStream}, +}; + +use super::{ + Error, + common_utils::{ + has_bearer_auth, has_header, messages_provider_config, string_headers, truncate_error_body, + }, + messages, + types::MessagesRequest, }; -use super::messages; -use super::types::MessagesRequest; async fn read_http_request(socket: &mut TcpStream) -> String { let mut request = Vec::new(); diff --git a/litellm-rust/crates/core/src/ocr/arguments.rs b/litellm-rust/crates/core/src/ocr/arguments.rs index a657ef0dc8a..2b27496fb5f 100644 --- a/litellm-rust/crates/core/src/ocr/arguments.rs +++ b/litellm-rust/crates/core/src/ocr/arguments.rs @@ -47,6 +47,17 @@ pub fn consumed_optional_param_names( .collect()) } +pub(crate) fn is_secret_param(name: &str) -> bool { + matches!( + name, + "azure_ad_token" + | "client_secret" + | "azure_federated_token_file" + | "vertex_credentials" + | "vertex_ai_credentials" + ) +} + pub fn consumed_optional_params( model: &str, custom_llm_provider: Option<&str>, @@ -56,14 +67,7 @@ pub fn consumed_optional_params( .into_iter() .map(|name| ArgumentSpec { name, - secret: matches!( - name, - "azure_ad_token" - | "client_secret" - | "azure_federated_token_file" - | "vertex_credentials" - | "vertex_ai_credentials" - ), + secret: is_secret_param(name), }) .collect() }) diff --git a/litellm-rust/crates/core/src/ocr/client.rs b/litellm-rust/crates/core/src/ocr/client.rs index 18d0f3b7498..bc8094953cf 100644 --- a/litellm-rust/crates/core/src/ocr/client.rs +++ b/litellm-rust/crates/core/src/ocr/client.rs @@ -1,14 +1,14 @@ -use std::sync::OnceLock; -use std::time::Duration; +use std::{sync::OnceLock, time::Duration}; use bytes::{Bytes, BytesMut}; use litellm_auth_gcp::VertexAuth; use serde::de::DeserializeOwned; -use super::json::{DecodedOcrResponse, decode_response}; -use super::types::{LiteLLMOcrRequest, LiteLLMOcrResponse}; -use crate::constants::OCR_CONNECT_TIMEOUT_SECS; -use crate::media::MediaFetcher; +use super::{ + json::{DecodedOcrResponse, decode_response}, + types::{LiteLLMOcrRequest, LiteLLMOcrResponse}, +}; +use crate::{constants::OCR_CONNECT_TIMEOUT_SECS, media::MediaFetcher}; #[derive(Clone)] pub struct OcrClient { @@ -37,36 +37,11 @@ impl OcrClient { &self, request: LiteLLMOcrRequest, ) -> Result { - use super::{ - NativeOutcome, OcrAdmission, OcrCall, OcrCallStep, OcrHookHost, OcrHost, - OcrHostOperation, OcrHostResult, - }; - - let host = OcrHookHost::new(request.hooks.clone()); - let mut request = Some(request); - let NativeOutcome::Completed(mut call) = OcrCall::admit(self.clone(), OcrAdmission::all()) - else { - return Err(crate::ocr::Error::InvalidRequest( - "native OCR host admission declined".into(), - )); - }; - let mut result = None; - loop { - match call.resume(result.take()).await? { - OcrCallStep::Host(OcrHostOperation::ProjectRequest) => { - result = Some(OcrHostResult::Request(Ok(( - Box::new(request.take().ok_or_else(|| { - crate::ocr::Error::InvalidRequest( - "OCR request was already projected".into(), - ) - })?), - false, - )))) - } - OcrCallStep::Host(operation) => result = Some(host.invoke(operation).await), - OcrCallStep::Complete(response) => return Ok(response), - } - } + litellm_callbacks::run::run( + super::ocr_machine(self.clone()), + &super::LocalOcrHost::new(request), + ) + .await } pub(crate) fn provider_http(&self) -> &reqwest::Client { diff --git a/litellm-rust/crates/core/src/ocr/document.rs b/litellm-rust/crates/core/src/ocr/document.rs index 5d1f0dd9ab4..a3515627dd7 100644 --- a/litellm-rust/crates/core/src/ocr/document.rs +++ b/litellm-rust/crates/core/src/ocr/document.rs @@ -1,20 +1,18 @@ -use std::collections::BTreeMap as Map; -use std::io::Read; -use std::path::Path; +use std::{collections::BTreeMap as Map, io::Read, path::Path}; use base64::{Engine, engine::general_purpose::STANDARD}; -use data_url::mime::Mime; -use data_url::{DataUrl, DataUrlError, forgiving_base64::DecodeError}; +use data_url::{DataUrl, DataUrlError, forgiving_base64::DecodeError, mime::Mime}; use reqwest::Url; -use super::Error as OcrError; -use super::Error as OcrRequestError; -use super::Error as OcrResponseError; -use super::types::{OcrConnection, OcrDocument, OcrDocumentInput}; -use crate::constants::{OCR_INLINE_MAX_BYTES, OCR_MAX_FETCH_REDIRECTS}; -use crate::media::Error as MediaError; -use crate::media::{DownloadPolicy, MediaFetcher}; -use crate::transport::Error as TransportError; +use super::{ + Error as OcrError, Error as OcrRequestError, Error as OcrResponseError, + types::{OcrConnection, OcrDocument, OcrDocumentInput}, +}; +use crate::{ + constants::{OCR_INLINE_MAX_BYTES, OCR_MAX_FETCH_REDIRECTS}, + media::{DownloadPolicy, Error as MediaError, MediaFetcher}, + transport::Error as TransportError, +}; pub fn prepare_document(input: OcrDocumentInput) -> Result { match input { @@ -396,8 +394,10 @@ mod tests { #[tokio::test] async fn remote_conversion_preserves_kind_and_isolates_provider_credentials() { - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - use tokio::net::TcpListener; + use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, + }; let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let address = listener.local_addr().unwrap(); diff --git a/litellm-rust/crates/core/src/ocr/handler.rs b/litellm-rust/crates/core/src/ocr/handler.rs index 7e42111da0a..450ac91f55d 100644 --- a/litellm-rust/crates/core/src/ocr/handler.rs +++ b/litellm-rust/crates/core/src/ocr/handler.rs @@ -1,36 +1,22 @@ -use std::sync::Arc; +use litellm_callbacks::event::{CallEvent, RawResponse}; -use super::OcrClient; -use super::hooks::{OcrHooks, OcrLifecycleHooks, OcrPostCallRequest}; -use super::types::{LiteLLMOcrResponse, PreparedOcrRequest, ResolvedOcrRequest}; -use crate::call_lifecycle::{CallLifecycle, CallLifecycleContext}; +use super::{ + OcrClient, + route::OcrHost, + types::{LiteLLMOcrResponse, PreparedOcrRequest, ResolvedOcrRequest}, +}; use crate::llms::base_llm::ocr::transformation::OcrResponseContext; pub(crate) async fn perform_ocr_request( client: &OcrClient, request: ResolvedOcrRequest, + host: &OcrHost, + caller_document: bool, ) -> Result { request.response_format()?; - let context = CallLifecycleContext::new( - "ocr", - request.model.clone(), - request.provider_name(), - request - .litellm_call_id - .clone() - .unwrap_or_else(|| format!("ocr-{:032x}", rand::random::())), - ); - let hooks = OcrLifecycleHooks { - hooks: request.hooks.clone(), - provider_name: context.custom_llm_provider.clone(), - }; - CallLifecycle::default() - .run(context, request, &hooks, |request| async move { - PreparedOcrCall::prepare(client.clone(), request) - .await? - .execute() - .await - }) + PreparedOcrCall::prepare(client.clone(), request, host, caller_document) + .await? + .execute() .await } @@ -44,8 +30,10 @@ impl PreparedOcrCall { pub(crate) async fn prepare( client: OcrClient, request: ResolvedOcrRequest, + host: &OcrHost, + caller_document: bool, ) -> Result { - let request = super::prepare::prepare_request(request); + let request = super::prepare::prepare_request(request, host.clone(), caller_document); let http = request.config.prepare_request(&request, &client).await?; Ok(Self { client, @@ -89,7 +77,7 @@ impl PreparedOcrCall { let context = OcrResponseContext { client: &self.client, connection: &self.request.connection, - hooks: &self.request.hooks, + host: &self.request.host, request_format: self.request.response_format()?, url: &url, headers: &headers, @@ -116,10 +104,14 @@ fn request_headers(request: &reqwest::Request) -> Result, .collect() } -pub(crate) async fn post_call(hooks: &Arc, bytes: &[u8]) -> Result<(), super::Error> { - let original_response = serde_json::Value::String(String::from_utf8_lossy(bytes).into_owned()); - hooks - .post_call(OcrPostCallRequest { original_response }) - .await?; - Ok(()) +pub(crate) async fn emit_response_received( + host: &OcrHost, + bytes: &[u8], +) -> Result<(), super::Error> { + host.emit(CallEvent::ResponseReceived { + raw: RawResponse { + body: String::from_utf8_lossy(bytes).into_owned(), + }, + }) + .await } diff --git a/litellm-rust/crates/core/src/ocr/hooks.rs b/litellm-rust/crates/core/src/ocr/hooks.rs deleted file mode 100644 index fdcf4fa05ba..00000000000 --- a/litellm-rust/crates/core/src/ocr/hooks.rs +++ /dev/null @@ -1,147 +0,0 @@ -use std::future::Future; -use std::pin::Pin; -use std::sync::Arc; - -use serde::Serialize; -use serde_json::Value; - -use super::types::{LiteLLMOcrRequest, LiteLLMOcrResponse, OcrDocument, ResolvedOcrRequest}; -use crate::call_lifecycle::{CallLifecycleContext, CallLifecycleHooks, CallLifecycleTiming}; -use crate::ocr::Error; - -pub type OcrHookFuture<'a, T> = Pin> + Send + 'a>>; -pub type OcrLogFuture<'a> = Pin + Send + 'a>>; - -#[derive(Clone, Debug, Serialize)] -pub struct OcrPreCallRequest { - pub model: String, - pub custom_llm_provider: String, - pub document: OcrDocument, - pub optional_params: Value, -} - -#[derive(Clone, Debug, Serialize)] -pub struct OcrDuringCallRequest { - pub model: String, - pub custom_llm_provider: String, - pub api_key: Option, - pub url: String, - pub headers: Vec<(String, String)>, - pub body: Value, - #[serde(skip)] - pub retained_fields: Vec, -} - -#[derive(Clone, Debug, Serialize)] -pub struct OcrPostCallRequest { - pub original_response: Value, -} - -pub trait OcrHooks: Send + Sync { - fn intercepts_requests(&self) -> bool { - false - } - fn pre_call(&self, request: OcrPreCallRequest) -> OcrHookFuture<'_, OcrPreCallRequest> { - Box::pin(async move { Ok(request) }) - } - fn during_call( - &self, - request: OcrDuringCallRequest, - ) -> OcrHookFuture<'_, OcrDuringCallRequest> { - Box::pin(async move { Ok(request) }) - } - fn post_call(&self, request: OcrPostCallRequest) -> OcrHookFuture<'_, OcrPostCallRequest> { - Box::pin(async move { Ok(request) }) - } - fn success<'a>( - &'a self, - _context: &'a CallLifecycleContext, - _response: &'a LiteLLMOcrResponse, - _timing: &'a CallLifecycleTiming, - ) -> OcrLogFuture<'a> { - Box::pin(async {}) - } - fn failure<'a>( - &'a self, - _context: &'a CallLifecycleContext, - _error: &'a Error, - _timing: &'a CallLifecycleTiming, - ) -> OcrLogFuture<'a> { - Box::pin(async {}) - } -} - -pub struct NoopOcrHooks; -impl OcrHooks for NoopOcrHooks {} - -pub(crate) struct OcrLifecycleHooks { - pub hooks: Arc, - pub provider_name: String, -} - -impl CallLifecycleHooks - for OcrLifecycleHooks -{ - type Error = crate::ocr::Error; - type PreCallFuture<'a> = OcrHookFuture<'a, ResolvedOcrRequest>; - type DuringCallFuture<'a> = OcrHookFuture<'a, ResolvedOcrRequest>; - type SuccessFuture<'a> = OcrLogFuture<'a>; - type FailureFuture<'a> = OcrLogFuture<'a>; - - fn async_pre_call_hook<'a>( - &'a self, - _context: &'a CallLifecycleContext, - request: ResolvedOcrRequest, - ) -> Self::PreCallFuture<'a> { - Box::pin(async move { - if !self.hooks.intercepts_requests() { - return Ok(request); - } - let changed = self - .hooks - .pre_call(OcrPreCallRequest { - model: request.model.clone(), - custom_llm_provider: self.provider_name.clone(), - document: request.document, - optional_params: Value::Object(request.optional_params.into()), - }) - .await?; - let Value::Object(optional_params) = changed.optional_params else { - return Err(super::Error::RequestField { - path: "guardrail.optional_params".into(), - }); - }; - Ok(LiteLLMOcrRequest { - document: changed.document, - optional_params: optional_params.into(), - ..request - }) - }) - } - - fn async_during_call_hook<'a>( - &'a self, - _context: &'a CallLifecycleContext, - request: ResolvedOcrRequest, - ) -> Self::DuringCallFuture<'a> { - Box::pin(async move { Ok(request) }) - } - - fn async_log_success_event<'a>( - &'a self, - context: &'a CallLifecycleContext, - response: &'a LiteLLMOcrResponse, - timing: &'a CallLifecycleTiming, - ) -> Self::SuccessFuture<'a> { - self.hooks.success(context, response, timing) - } - - fn async_log_failure_event<'a>( - &'a self, - context: &'a CallLifecycleContext, - error: &'a Error, - timing: &'a CallLifecycleTiming, - ) -> Self::FailureFuture<'a> { - self.hooks.failure(context, error, timing) - } -} diff --git a/litellm-rust/crates/core/src/ocr/lifecycle.rs b/litellm-rust/crates/core/src/ocr/lifecycle.rs deleted file mode 100644 index f2e5479b361..00000000000 --- a/litellm-rust/crates/core/src/ocr/lifecycle.rs +++ /dev/null @@ -1,727 +0,0 @@ -use std::future::Future; -use std::pin::Pin; -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; - -use litellm_auth::Error as AuthError; -use litellm_auth::{ResolvedCredential, TokenFuture, TokenProvider, TokenProviderHandle}; -use tokio::sync::{Notify, mpsc, oneshot}; - -use super::handler::perform_ocr_request; -use super::hooks::{ - OcrDuringCallRequest, OcrHookFuture, OcrHooks, OcrLogFuture, OcrPostCallRequest, - OcrPreCallRequest, -}; -use super::types::{OcrDocumentInput, OcrFileContent}; -use super::{LiteLLMOcrRequest, LiteLLMOcrResponse, OcrClient}; -use crate::call_lifecycle::host::{ - HostCall, HostCallFuture, HostCallStep, HostFailure, HostLifecycle, HostPhase, -}; -use crate::call_lifecycle::{CallLifecycleContext, CallLifecycleTiming}; -use crate::ocr::Error; - -pub type NativeResult = Result, Error>; - -#[derive(Debug, PartialEq, Eq)] -pub enum NativeOutcome { - Completed(T), - Declined(OcrDecline), -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum OcrDecline { - ProviderWorkflow, - HostOperations, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct OcrAdmission { - pub provider_workflow: bool, - pub host_operations: bool, - pub asynchronous: bool, -} - -impl OcrAdmission { - pub const fn all() -> Self { - Self { - provider_workflow: true, - host_operations: true, - asynchronous: false, - } - } -} - -#[derive(Clone, Debug)] -pub enum OcrHostOperation { - ProjectRequest, - ReadDocument, - Lifecycle(HostPhase), - ConstructResponse(Arc), - MapFailure(Error), - Success { - context: CallLifecycleContext, - response: Arc, - timing: CallLifecycleTiming, - }, - Failure { - context: CallLifecycleContext, - error: Error, - timing: CallLifecycleTiming, - }, - AcquireAzureAdToken, - PreCall(OcrPreCallRequest), - DuringCall(OcrDuringCallRequest), - PostCall(OcrPostCallRequest), -} - -impl OcrHostOperation { - pub const fn phase(&self) -> Option { - match self { - Self::Lifecycle(phase) => Some(*phase), - Self::Success { .. } => Some(HostPhase::Success), - Self::Failure { .. } => Some(HostPhase::Failure), - _ => None, - } - } -} - -pub enum OcrHostResult { - Request(Result<(Box>, bool), Error>), - Document(Result), - Lifecycle(Result<(), HostFailure>), - AzureAdToken(Result), - PreCall(Result), - DuringCall(Result), - PostCall(Result), -} - -pub type OcrCallStep = HostCallStep; - -pub struct OcrCall { - lifecycle: HostLifecycle, - execution: OcrExecution, - response: Option>, - error: Option, - pending: bool, - completed: bool, - projecting: bool, -} - -impl OcrCall { - pub fn admit(client: OcrClient, admission: OcrAdmission) -> NativeOutcome { - if !admission.provider_workflow { - return NativeOutcome::Declined(OcrDecline::ProviderWorkflow); - } - if !admission.host_operations { - return NativeOutcome::Declined(OcrDecline::HostOperations); - } - NativeOutcome::Completed(Self { - lifecycle: HostLifecycle::new(admission.asynchronous), - execution: OcrExecution::new(client), - response: None, - error: None, - pending: false, - completed: false, - projecting: false, - }) - } - - pub async fn resume(&mut self, result: Option) -> Result { - if self.completed { - return Err(Error::InvalidRequest( - "OCR call cannot be resumed after completion".into(), - )); - } - if self.pending != result.is_some() { - return Err(Error::InvalidRequest( - "OCR host operation result does not match pending state".into(), - )); - } - match &result { - Some(OcrHostResult::Lifecycle(Ok(()))) - if self.lifecycle.phase() == HostPhase::Execute => - { - return Err(Error::InvalidRequest( - "OCR provider operation requires a typed result".into(), - )); - } - Some(result) - if !matches!(result, OcrHostResult::Lifecycle(_)) - && self.lifecycle.phase() != HostPhase::Execute => - { - return Err(Error::InvalidRequest( - "unexpected OCR provider operation result".into(), - )); - } - _ => {} - } - self.pending = false; - let provider_result = match result { - Some(OcrHostResult::Request(result)) if self.projecting => { - self.projecting = false; - match result { - Ok((request, azure_ad_token_provider)) => { - self.execution.request = Some(*request); - self.execution.azure_ad_token_provider = azure_ad_token_provider; - } - Err(error) => self.accept(Err(HostFailure::Error(error))), - } - None - } - Some(OcrHostResult::Request(_)) => { - return Err(Error::InvalidRequest( - "unexpected OCR request projection".into(), - )); - } - Some(OcrHostResult::Lifecycle(result)) => { - self.accept(result); - None - } - result => result, - }; - if self.lifecycle.phase() == HostPhase::Execute { - if self.execution.request.is_none() - && self.execution.execution.is_none() - && !self.execution.completed - { - self.projecting = true; - return Ok(self.host_step(OcrHostOperation::ProjectRequest)); - } - match self.execution.resume(provider_result).await { - Ok(OcrCallStep::Host(operation)) => return Ok(self.host_step(operation)), - Ok(OcrCallStep::Complete(response)) => { - self.response = Some(Arc::new(response)); - self.accept(Ok(())); - } - Err(error) => self.accept(Err(HostFailure::Error(error))), - } - } - if self.error.is_some() { - self.execution.stop().await; - } - let operation = match self.lifecycle.phase() { - HostPhase::Complete => { - self.completed = true; - return match self.error.take() { - Some(error) => Err(error), - None => self - .response - .take() - .map(Arc::unwrap_or_clone) - .map(OcrCallStep::Complete) - .ok_or_else(|| { - Error::InvalidRequest("OCR completed without a response".into()) - }), - }; - } - HostPhase::ConstructResponse => OcrHostOperation::ConstructResponse( - self.response - .as_ref() - .ok_or_else(|| Error::InvalidRequest("missing OCR response".into()))? - .clone(), - ), - HostPhase::MapFailure => OcrHostOperation::MapFailure( - self.error - .as_ref() - .ok_or_else(|| Error::InvalidRequest("missing OCR failure".into()))? - .clone(), - ), - HostPhase::Success | HostPhase::Failure => { - let snapshot = self - .execution - .terminal - .lock() - .unwrap_or_else(|error| error.into_inner()) - .clone(); - match (self.lifecycle.phase(), snapshot) { - (HostPhase::Success, Some((context, timing))) => OcrHostOperation::Success { - context, - response: self - .response - .as_ref() - .ok_or_else(|| Error::InvalidRequest("missing OCR response".into()))? - .clone(), - timing, - }, - (HostPhase::Failure, Some((context, timing))) => OcrHostOperation::Failure { - context, - error: self - .error - .as_ref() - .ok_or_else(|| Error::InvalidRequest("missing OCR failure".into()))? - .clone(), - timing, - }, - (phase, _) => OcrHostOperation::Lifecycle(phase), - } - } - phase => OcrHostOperation::Lifecycle(phase), - }; - Ok(self.host_step(operation)) - } - - fn accept(&mut self, result: Result<(), HostFailure>) { - let cancelled = matches!(&result, Err(HostFailure::Cancelled(_))); - if let Some(error) = self.lifecycle.accept(result) { - if cancelled { - self.error = Some(error); - } else { - self.error.get_or_insert(error); - } - self.execution.cancel(); - } - } - - pub async fn interrupt(&mut self, failure: HostFailure) -> Result { - if self.completed { - return Err(Error::InvalidRequest( - "OCR call cannot be interrupted after completion".into(), - )); - } - self.pending = false; - self.accept(Err(failure)); - self.resume(None).await - } - - fn host_step(&mut self, operation: OcrHostOperation) -> OcrCallStep { - self.pending = true; - OcrCallStep::Host(operation) - } -} - -impl HostCall for OcrCall { - type Error = crate::ocr::Error; - type Operation = OcrHostOperation; - type Result = OcrHostResult; - type Complete = LiteLLMOcrResponse; - - fn resume( - &mut self, - result: Option, - ) -> HostCallFuture<'_, Self::Operation, Self::Complete, Self::Error> { - Box::pin(OcrCall::resume(self, result)) - } - - fn interrupt( - &mut self, - failure: HostFailure, - ) -> HostCallFuture<'_, Self::Operation, Self::Complete, Self::Error> { - Box::pin(OcrCall::interrupt(self, failure)) - } -} - -struct PendingOperation { - operation: OcrHostOperation, - result: oneshot::Sender, -} - -struct OcrExecution { - client: Option, - request: Option>, - operations_tx: mpsc::UnboundedSender, - operations_rx: mpsc::UnboundedReceiver, - pending_result: Option>, - execution: Option>>, - blocking_preparation: Arc, - completed: bool, - azure_ad_token_provider: bool, - terminal: Arc>>, -} - -impl OcrExecution { - fn new(client: OcrClient) -> Self { - let (operations_tx, operations_rx) = mpsc::unbounded_channel(); - Self { - client: Some(client), - request: None, - operations_tx, - operations_rx, - pending_result: None, - execution: None, - blocking_preparation: Arc::new(BlockingPreparation::default()), - completed: false, - azure_ad_token_provider: false, - terminal: Arc::default(), - } - } - - pub async fn resume(&mut self, result: Option) -> Result { - if self.completed { - return Err(Error::InvalidRequest( - "OCR call cannot be resumed after completion".into(), - )); - } - match (self.pending_result.take(), result) { - (Some(sender), Some(result)) => sender - .send(result) - .map_err(|_| Error::InvalidRequest("OCR host operation was abandoned".into()))?, - (None, None) if self.execution.is_none() => self.start(), - (Some(sender), None) => { - self.pending_result = Some(sender); - return Err(Error::InvalidRequest( - "OCR host operation result is required".into(), - )); - } - (None, Some(_)) => { - return Err(Error::InvalidRequest( - "unexpected OCR host operation result".into(), - )); - } - (None, None) => {} - } - - let execution = self.execution.as_mut().ok_or_else(|| { - Error::InvalidRequest("OCR call cannot be resumed after completion".into()) - })?; - tokio::select! { - operation = self.operations_rx.recv() => { - let operation = operation.ok_or_else(|| Error::InvalidRequest("OCR operation channel closed".into()))?; - self.pending_result = Some(operation.result); - Ok(OcrCallStep::Host(operation.operation)) - } - result = execution => { - self.execution = None; - self.completed = true; - result - .map_err(|error| Error::Transport(crate::transport::Error::Network(format!("OCR execution task failed: {error}"))))? - .map(OcrCallStep::Complete) - } - } - } - - fn start(&mut self) { - let client = self.client.take().expect("admitted OCR call has a client"); - let mut request = self - .request - .take() - .expect("admitted OCR call has a request"); - let intercepts_requests = request.hooks.intercepts_requests(); - if self.azure_ad_token_provider { - request.azure_ad_token_provider = Some(TokenProviderHandle::new(Arc::new( - OcrAzureAdTokenProvider { - operations: self.operations_tx.clone(), - }, - ))); - } - let hooks = Arc::new(ProtocolHooks { - operations: self.operations_tx.clone(), - intercepts_requests, - terminal: self.terminal.clone(), - }); - request.hooks = hooks.clone(); - let blocking_preparation = self.blocking_preparation.clone(); - self.execution = Some(tokio::spawn(async move { - let request = prepare_request_document(request, &hooks, blocking_preparation).await?; - perform_ocr_request(&client, request).await - })); - } - - fn cancel(&mut self) { - self.pending_result = None; - if let Some(execution) = &self.execution { - execution.abort(); - } - } - - async fn stop(&mut self) { - self.cancel(); - if let Some(execution) = self.execution.as_mut() { - let _ = execution.await; - } - self.blocking_preparation.wait().await; - self.execution = None; - } -} - -#[derive(Default)] -struct BlockingPreparation { - running: AtomicBool, - finished: Notify, -} - -impl BlockingPreparation { - fn start(self: &Arc) -> BlockingPreparationGuard { - self.running.store(true, Ordering::Release); - BlockingPreparationGuard(self.clone()) - } - - async fn wait(&self) { - loop { - let finished = self.finished.notified(); - if !self.running.load(Ordering::Acquire) { - return; - } - finished.await; - } - } -} - -struct BlockingPreparationGuard(Arc); - -impl Drop for BlockingPreparationGuard { - fn drop(&mut self) { - self.0.running.store(false, Ordering::Release); - self.0.finished.notify_waiters(); - } -} - -async fn prepare_request_document( - request: LiteLLMOcrRequest, - hooks: &ProtocolHooks, - blocking_preparation: Arc, -) -> Result { - let request = match &request.document { - OcrDocumentInput::HostReader { mime_type } => { - let mime_type = mime_type.clone(); - let content = match hooks.invoke(OcrHostOperation::ReadDocument).await? { - OcrHostResult::Document(result) => result?, - _ => { - return Err(Error::InvalidRequest( - "invalid OCR document read host result".into(), - )); - } - }; - request.with_document(OcrDocumentInput::Bytes { - bytes: content.bytes, - file_name: content.file_name, - mime_type, - }) - } - _ => request, - }; - if let OcrDocumentInput::Document(_) = &request.document { - return request.map_document(super::document::prepare_document); - } - let guard = blocking_preparation.start(); - tokio::task::spawn_blocking(move || { - let _guard = guard; - request.map_document(super::document::prepare_document) - }) - .await - .map_err(|error| { - Error::InvalidRequest(format!("OCR document preparation task failed: {error}")) - })? -} - -impl Drop for OcrExecution { - fn drop(&mut self) { - if let Some(execution) = &self.execution { - execution.abort(); - } - } -} - -struct ProtocolHooks { - operations: mpsc::UnboundedSender, - intercepts_requests: bool, - terminal: Arc>>, -} - -#[derive(Debug)] -struct OcrAzureAdTokenProvider { - operations: mpsc::UnboundedSender, -} - -impl TokenProvider for OcrAzureAdTokenProvider { - fn acquire(&self) -> TokenFuture<'_> { - Box::pin(async move { - let (result, receiver) = oneshot::channel(); - self.operations - .send(PendingOperation { - operation: OcrHostOperation::AcquireAzureAdToken, - result, - }) - .map_err(|_| { - AuthError::AzureTokenAcquisition("OCR host driver was abandoned".into()) - })?; - match receiver.await.map_err(|_| { - AuthError::AzureTokenAcquisition( - "OCR token provider operation was abandoned".into(), - ) - })? { - OcrHostResult::AzureAdToken(result) => result, - _ => Err(AuthError::AzureTokenAcquisition( - "invalid OCR token provider host result".into(), - )), - } - }) - } -} - -impl ProtocolHooks { - async fn invoke(&self, operation: OcrHostOperation) -> Result { - let (result, receiver) = oneshot::channel(); - self.operations - .send(PendingOperation { operation, result }) - .map_err(|_| Error::InvalidRequest("OCR host driver was abandoned".into()))?; - receiver - .await - .map_err(|_| Error::InvalidRequest("OCR host operation was abandoned".into())) - } -} - -impl OcrHooks for ProtocolHooks { - fn intercepts_requests(&self) -> bool { - self.intercepts_requests - } - - fn pre_call(&self, request: OcrPreCallRequest) -> OcrHookFuture<'_, OcrPreCallRequest> { - Box::pin(async move { - match self.invoke(OcrHostOperation::PreCall(request)).await? { - OcrHostResult::PreCall(result) => result, - _ => Err(Error::InvalidRequest( - "invalid OCR pre-call host result".into(), - )), - } - }) - } - - fn during_call( - &self, - request: OcrDuringCallRequest, - ) -> OcrHookFuture<'_, OcrDuringCallRequest> { - Box::pin(async move { - match self.invoke(OcrHostOperation::DuringCall(request)).await? { - OcrHostResult::DuringCall(result) => result, - _ => Err(Error::InvalidRequest( - "invalid OCR during-call host result".into(), - )), - } - }) - } - - fn post_call(&self, request: OcrPostCallRequest) -> OcrHookFuture<'_, OcrPostCallRequest> { - Box::pin(async move { - match self.invoke(OcrHostOperation::PostCall(request)).await? { - OcrHostResult::PostCall(result) => result, - _ => Err(Error::InvalidRequest( - "invalid OCR post-call host result".into(), - )), - } - }) - } - - fn success<'a>( - &'a self, - context: &'a CallLifecycleContext, - _response: &'a LiteLLMOcrResponse, - timing: &'a CallLifecycleTiming, - ) -> OcrLogFuture<'a> { - Box::pin(async move { - *self - .terminal - .lock() - .unwrap_or_else(|error| error.into_inner()) = - Some((context.clone(), timing.clone())); - }) - } - - fn failure<'a>( - &'a self, - context: &'a CallLifecycleContext, - _error: &'a Error, - timing: &'a CallLifecycleTiming, - ) -> OcrLogFuture<'a> { - Box::pin(async move { - *self - .terminal - .lock() - .unwrap_or_else(|error| error.into_inner()) = - Some((context.clone(), timing.clone())); - }) - } -} - -pub type OcrHostFuture<'a> = Pin + Send + 'a>>; - -pub trait OcrHost: Send + Sync { - fn invoke(&self, operation: OcrHostOperation) -> OcrHostFuture<'_>; -} - -pub struct NoopOcrHost; - -impl OcrHost for NoopOcrHost { - fn invoke(&self, operation: OcrHostOperation) -> OcrHostFuture<'_> { - Box::pin(async move { - match operation { - OcrHostOperation::ProjectRequest => OcrHostResult::Request(Err( - Error::InvalidRequest("OCR host has no request projection".into()), - )), - OcrHostOperation::ReadDocument => OcrHostResult::Document(Err( - Error::InvalidRequest("OCR host has no document reader".into()), - )), - OcrHostOperation::Lifecycle(_) - | OcrHostOperation::ConstructResponse(_) - | OcrHostOperation::MapFailure(_) - | OcrHostOperation::Success { .. } - | OcrHostOperation::Failure { .. } => OcrHostResult::Lifecycle(Ok(())), - OcrHostOperation::AcquireAzureAdToken => { - OcrHostResult::AzureAdToken(Err(AuthError::AzureTokenAcquisition( - "OCR host has no Azure AD token provider".into(), - ))) - } - OcrHostOperation::PreCall(request) => OcrHostResult::PreCall(Ok(request)), - OcrHostOperation::DuringCall(request) => OcrHostResult::DuringCall(Ok(request)), - OcrHostOperation::PostCall(request) => OcrHostResult::PostCall(Ok(request)), - } - }) - } -} - -pub struct OcrHookHost { - hooks: Arc, -} - -impl OcrHookHost { - pub fn new(hooks: Arc) -> Self { - Self { hooks } - } -} - -impl OcrHost for OcrHookHost { - fn invoke(&self, operation: OcrHostOperation) -> OcrHostFuture<'_> { - Box::pin(async move { - match operation { - OcrHostOperation::ProjectRequest => OcrHostResult::Request(Err( - Error::InvalidRequest("OCR hook host has no request projection".into()), - )), - OcrHostOperation::ReadDocument => OcrHostResult::Document(Err( - Error::InvalidRequest("OCR hook host has no document reader".into()), - )), - OcrHostOperation::Success { - context, - response, - timing, - } => { - self.hooks.success(&context, &response, &timing).await; - OcrHostResult::Lifecycle(Ok(())) - } - OcrHostOperation::Failure { - context, - error, - timing, - } => { - self.hooks.failure(&context, &error, &timing).await; - OcrHostResult::Lifecycle(Ok(())) - } - OcrHostOperation::Lifecycle(_) - | OcrHostOperation::ConstructResponse(_) - | OcrHostOperation::MapFailure(_) => OcrHostResult::Lifecycle(Ok(())), - OcrHostOperation::AcquireAzureAdToken => { - OcrHostResult::AzureAdToken(Err(AuthError::AzureTokenAcquisition( - "OCR hook host has no Azure AD token provider".into(), - ))) - } - OcrHostOperation::PreCall(request) => { - OcrHostResult::PreCall(self.hooks.pre_call(request).await) - } - OcrHostOperation::DuringCall(request) => { - OcrHostResult::DuringCall(self.hooks.during_call(request).await) - } - OcrHostOperation::PostCall(request) => { - OcrHostResult::PostCall(self.hooks.post_call(request).await) - } - } - }) - } -} diff --git a/litellm-rust/crates/core/src/ocr/mod.rs b/litellm-rust/crates/core/src/ocr/mod.rs index 943d99c74e3..75d85da7957 100644 --- a/litellm-rust/crates/core/src/ocr/mod.rs +++ b/litellm-rust/crates/core/src/ocr/mod.rs @@ -4,11 +4,10 @@ pub(crate) mod document; pub mod error; pub use error::Error; pub(crate) mod handler; -pub mod hooks; pub(crate) mod json; -mod lifecycle; pub(crate) mod prepare; mod provider_config; +pub mod route; pub mod types; pub mod wire; @@ -17,11 +16,8 @@ pub use arguments::{ }; pub use client::{OcrClient, ocr}; pub use document::{encode_file_document, mime_type_for_name, read_path_document}; -pub use lifecycle::{ - NativeOutcome, NativeResult, NoopOcrHost, OcrAdmission, OcrCall, OcrCallStep, OcrDecline, - OcrHookHost, OcrHost, OcrHostOperation, OcrHostResult, -}; pub use provider_config::{get_api_key_env_var, get_health_check_document}; +pub use route::{LocalOcrHost, Ocr, OcrHost, OcrMachine, OcrOp, OcrOpResult, ocr_machine}; pub use types::{ LiteLLMOcrRequest, LiteLLMOcrResponse, OcrConnection, OcrConnectionInputs, OcrCredentialInputs, OcrDocument, OcrDocumentInput, OcrFileContent, OcrPage, OcrPageDimensions, OcrPageImage, @@ -38,6 +34,9 @@ mod azure_document_intelligence_tests; #[path = "../../tests/deepseek_ocr.rs"] mod deepseek_tests; #[cfg(test)] +#[path = "../../tests/ocr/passthrough.rs"] +mod passthrough_tests; +#[cfg(test)] #[path = "../../tests/reducto_ocr.rs"] mod reducto_tests; #[cfg(test)] diff --git a/litellm-rust/crates/core/src/ocr/prepare.rs b/litellm-rust/crates/core/src/ocr/prepare.rs index 111c5f7e97a..2de72660794 100644 --- a/litellm-rust/crates/core/src/ocr/prepare.rs +++ b/litellm-rust/crates/core/src/ocr/prepare.rs @@ -1,8 +1,9 @@ +use litellm_callbacks::event::{Passthrough, RequestContext, WireRequest}; use serde::Serialize; -use serde_json::Value; +use serde_json::{Map, Value}; use super::OcrClient; -use super::hooks::OcrDuringCallRequest; +use super::route::OcrHost; use super::types::{OcrConnection, OcrDocument, PreparedOcrRequest, ResolvedOcrRequest}; pub(crate) async fn transform_request_body( @@ -22,56 +23,62 @@ where request.config.get_supported_ocr_params(&request.model), )?; validate(&composed)?; - let retained_fields = request - .optional_params - .keys() - .filter(|name| composed.get(*name).is_some()) - .cloned() - .chain( - composed - .get("document") - .is_some() - .then(|| "document".to_string()), + let passthrough_fields = Passthrough::unchanged(&caller_inputs(request)?, &composed); + let changed = request + .host + .before_send( + wire_request(url, headers, composed), + request_context(request, passthrough_fields), ) - .collect(); - let original_document = - serde_json::to_value(&request.document).map_err(|_| super::Error::RequestField { + .await?; + if !changed.body.is_object() { + return Err(super::Error::RequestField { + path: "guardrail.body".into(), + }); + } + validate(&changed.body)?; + build_http_request(client, request, url, &changed.headers, &changed.body) +} + +fn wire_request(url: &str, headers: &[(String, String)], body: Value) -> WireRequest { + WireRequest { + url: url.into(), + headers: headers.to_vec(), + body, + } +} + +fn caller_inputs(request: &PreparedOcrRequest) -> Result, super::Error> { + let document = request + .caller_document + .then(|| serde_json::to_value(&request.document)) + .transpose() + .map_err(|_| super::Error::RequestField { path: "document".into(), })?; - let prepared_document = composed - .get("document") - .filter(|prepared| **prepared != original_document) - .cloned(); - let (body, headers) = if request.hooks.intercepts_requests() { - let changed = request - .hooks - .during_call(OcrDuringCallRequest { - model: request.model.clone(), - custom_llm_provider: request.provider_name().into(), - api_key: request.connection.api_key.clone(), - url: url.into(), - headers: headers.to_vec(), - body: composed, - retained_fields, - }) - .await?; - let Value::Object(mut fields) = changed.body else { - return Err(super::Error::RequestField { - path: "guardrail.body".into(), - }); - }; - if let Some(prepared) = - prepared_document.filter(|_| fields.get("document") == Some(&original_document)) - { - fields.insert("document".into(), prepared); - } - let body = Value::Object(fields); - validate(&body)?; - (body, changed.headers) - } else { - (composed, headers.to_vec()) - }; - build_http_request(client, request, url, &headers, &body) + let params: Map = request.optional_params.clone().into(); + Ok(params + .into_iter() + .chain(document.map(|document| ("document".to_string(), document))) + .collect()) +} + +fn request_context( + request: &PreparedOcrRequest, + passthrough_fields: Passthrough, +) -> RequestContext { + RequestContext { + model: request.model.clone(), + custom_llm_provider: request.provider_name().into(), + optional_params: Value::Object(request.optional_params.clone().into()), + passthrough_fields, + secret_fields: request + .optional_params + .keys() + .filter(|name| super::arguments::is_secret_param(name)) + .cloned() + .collect(), + } } pub(crate) fn build_http_request( @@ -97,24 +104,15 @@ pub(crate) async fn guardrail_document( url: &str, headers: &[(String, String)], ) -> Result<(OcrDocument, Vec<(String, String)>), super::Error> { - if !request.hooks.intercepts_requests() { - return Ok((request.document.clone(), headers.to_vec())); - } + let body = serde_json::to_value(&request.document).map_err(|_| super::Error::RequestField { + path: "document".into(), + })?; let changed = request - .hooks - .during_call(OcrDuringCallRequest { - model: request.model.clone(), - custom_llm_provider: request.provider_name().into(), - api_key: request.connection.api_key.clone(), - url: url.into(), - headers: headers.to_vec(), - body: serde_json::to_value(&request.document).map_err(|_| { - super::Error::RequestField { - path: "document".into(), - } - })?, - retained_fields: Vec::new(), - }) + .host + .before_send( + wire_request(url, headers, body), + request_context(request, Passthrough::default()), + ) .await?; let document = super::json::decode_request_value(changed.body, "guardrail.document")?; Ok((document, changed.headers)) @@ -139,7 +137,11 @@ pub(crate) fn credential_env(name: &str) -> Option { std::env::var(name).ok() } -pub(crate) fn prepare_request(request: ResolvedOcrRequest) -> PreparedOcrRequest { +pub(crate) fn prepare_request( + request: ResolvedOcrRequest, + host: OcrHost, + caller_document: bool, +) -> PreparedOcrRequest { use litellm_auth::{InputSource, Sourced}; let credentials = request.credentials.clone(); @@ -174,7 +176,17 @@ pub(crate) fn prepare_request(request: ResolvedOcrRequest) -> PreparedOcrRequest ..credentials }); let transport = request.transport.clone(); - PreparedOcrRequest::new(request, OcrConnection::new(resolved, transport)) + PreparedOcrRequest::new( + request, + OcrConnection::new(resolved, transport), + host, + caller_document, + ) +} + +#[cfg(test)] +pub(crate) fn prepare_request_for_test(request: ResolvedOcrRequest) -> PreparedOcrRequest { + prepare_request(request, OcrHost::detached(), true) } #[cfg(test)] diff --git a/litellm-rust/crates/core/src/ocr/provider_config.rs b/litellm-rust/crates/core/src/ocr/provider_config.rs index b798fd95841..0121f2dfdf4 100644 --- a/litellm-rust/crates/core/src/ocr/provider_config.rs +++ b/litellm-rust/crates/core/src/ocr/provider_config.rs @@ -1,22 +1,29 @@ use strum::{EnumString, IntoStaticStr}; -use super::OcrClient; -use super::types::{ - LiteLLMOcrResponse, OcrCredentialInputs, OcrDocument, PreparedOcrRequest, - ResolvedOcrCredentials, +use super::{ + OcrClient, + types::{ + LiteLLMOcrResponse, OcrCredentialInputs, OcrDocument, PreparedOcrRequest, + ResolvedOcrCredentials, + }, }; -use crate::litellm_core_utils::get_llm_provider_logic::{ - CustomLlmProvider, get_custom_llm_provider, +use crate::{ + litellm_core_utils::get_llm_provider_logic::{CustomLlmProvider, get_custom_llm_provider}, + llms::{ + azure_ai::ocr::{ + cohere_parse_transformation::AzureAICohereParseConfig, + document_intelligence::transformation::AzureDocumentIntelligenceOcrConfig, + transformation::AzureAiOcrConfig, + }, + base_llm::ocr::transformation::{BaseOcrConfig, OcrResponseContext}, + cohere::ocr::transformation::CohereParseConfig, + mistral::ocr::transformation::MistralOcrConfig, + reducto::ocr::transformation::{ReductoParseLegacyConfig, ReductoParseV3Config}, + vertex_ai::ocr::{ + deepseek_transformation::VertexAIDeepSeekOCRConfig, transformation::VertexAiOcrConfig, + }, + }, }; -use crate::llms::azure_ai::ocr::cohere_parse_transformation::AzureAICohereParseConfig; -use crate::llms::azure_ai::ocr::document_intelligence::transformation::AzureDocumentIntelligenceOcrConfig; -use crate::llms::azure_ai::ocr::transformation::AzureAiOcrConfig; -use crate::llms::base_llm::ocr::transformation::{BaseOcrConfig, OcrResponseContext}; -use crate::llms::cohere::ocr::transformation::CohereParseConfig; -use crate::llms::mistral::ocr::transformation::MistralOcrConfig; -use crate::llms::reducto::ocr::transformation::{ReductoParseLegacyConfig, ReductoParseV3Config}; -use crate::llms::vertex_ai::ocr::deepseek_transformation::VertexAIDeepSeekOCRConfig; -use crate::llms::vertex_ai::ocr::transformation::VertexAiOcrConfig; macro_rules! dispatch_config { ($config:expr, $method:ident($($argument:expr),* $(,)?)) => { diff --git a/litellm-rust/crates/core/src/ocr/route.rs b/litellm-rust/crates/core/src/ocr/route.rs new file mode 100644 index 00000000000..50058ac90fa --- /dev/null +++ b/litellm-rust/crates/core/src/ocr/route.rs @@ -0,0 +1,217 @@ +use std::sync::{Arc, Mutex}; + +use litellm_auth::ResolvedCredential; +use litellm_callbacks::{ + event::{CallEvent, RequestContext, WireRequest}, + route::Route, +}; + +use super::{ + Error, LiteLLMOcrRequest, LiteLLMOcrResponse, OcrClient, + handler::perform_ocr_request, + types::{OcrDocumentInput, OcrFileContent, ResolvedOcrRequest}, +}; +use crate::machine::{HostChannel, HostTokenProvider, MachineFault, RouteMachine, TokenRoute}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum OcrOp { + ProjectRequest, + ReadDocument, + AcquireAzureAdToken, +} + +pub enum OcrOpResult { + Request { + request: Box>, + caller_token: bool, + }, + Document(OcrFileContent), + AzureAdToken(ResolvedCredential), +} + +pub struct Ocr; + +impl Route for Ocr { + type Response = LiteLLMOcrResponse; + type Error = Error; + type Op = OcrOp; + type OpResult = OcrOpResult; +} + +impl TokenRoute for Ocr { + fn acquire_token_op() -> OcrOp { + OcrOp::AcquireAzureAdToken + } + + fn token_credential(result: OcrOpResult) -> Option { + match result { + OcrOpResult::AzureAdToken(credential) => Some(credential), + _ => None, + } + } +} + +impl From for Error { + fn from(fault: MachineFault) -> Self { + Self::InvalidRequest(match fault { + MachineFault::Abandoned => "OCR host driver was abandoned".into(), + MachineFault::Protocol(message) => format!("OCR {message}"), + MachineFault::Mismatch => "invalid OCR host operation result".into(), + }) + } +} + +pub type OcrHost = HostChannel; +pub type OcrMachine = RouteMachine; + +/// The OCR call as a machine: projection, document reading and token acquisition are +/// host operations; everything else runs in Rust. +pub fn ocr_machine(client: OcrClient) -> OcrMachine { + RouteMachine::new(move |host| Box::pin(execute(client, host))) +} + +async fn execute(client: OcrClient, host: OcrHost) -> Result { + let OcrOpResult::Request { + request, + caller_token, + } = host.route(OcrOp::ProjectRequest).await? + else { + return Err(MachineFault::Mismatch.into()); + }; + let request = LiteLLMOcrRequest { + azure_ad_token_provider: caller_token + .then(|| HostTokenProvider::handle(host.clone())) + .or(request.azure_ad_token_provider), + ..*request + }; + let caller_document = matches!(request.document, OcrDocumentInput::Document(_)); + let request = prepare_request_document(request, &host).await?; + perform_ocr_request(&client, request, &host, caller_document).await +} + +async fn prepare_request_document( + request: LiteLLMOcrRequest, + host: &OcrHost, +) -> Result { + let request = match &request.document { + OcrDocumentInput::HostReader { mime_type } => { + let mime_type = mime_type.clone(); + let OcrOpResult::Document(content) = host.route(OcrOp::ReadDocument).await? else { + return Err(MachineFault::Mismatch.into()); + }; + request.with_document(OcrDocumentInput::Bytes { + bytes: content.bytes, + file_name: content.file_name, + mime_type, + }) + } + _ => request, + }; + if let OcrDocumentInput::Document(_) = &request.document { + return request.map_document(super::document::prepare_document); + } + tokio::task::spawn_blocking(move || request.map_document(super::document::prepare_document)) + .await + .map_err(|error| Error::DocumentTask(Arc::new(error)))? +} + +type Reader = Box Result + Send + Sync>; +type BeforeSend = + Box Result + Send + Sync>; +type Observer = Box; + +/// The in-process host for a request that is already in hand: the request answers +/// projection, and the optional observer sees and may rewrite the wire request. +pub struct LocalOcrHost { + request: Mutex>>, + reader: Option, + before_send: Option, + observer: Option, +} + +impl LocalOcrHost { + pub fn new(request: LiteLLMOcrRequest) -> Self { + Self { + request: Mutex::new(Some(request)), + reader: None, + before_send: None, + observer: None, + } + } + + pub fn with_reader( + self, + reader: impl Fn() -> Result + Send + Sync + 'static, + ) -> Self { + Self { + reader: Some(Box::new(reader)), + ..self + } + } + + pub fn with_before_send( + self, + before_send: impl Fn(WireRequest, &RequestContext) -> Result + + Send + + Sync + + 'static, + ) -> Self { + Self { + before_send: Some(Box::new(before_send)), + ..self + } + } + + pub fn with_observer(self, observer: impl Fn(&CallEvent) + Send + Sync + 'static) -> Self { + Self { + observer: Some(Box::new(observer)), + ..self + } + } +} + +impl litellm_callbacks::host::Host for LocalOcrHost { + async fn route(&self, op: OcrOp) -> Result { + match op { + OcrOp::ProjectRequest => self + .request + .lock() + .unwrap_or_else(|error| error.into_inner()) + .take() + .map(|request| OcrOpResult::Request { + request: Box::new(request), + caller_token: false, + }) + .ok_or_else(|| Error::InvalidRequest("OCR request was already projected".into())), + OcrOp::ReadDocument => self + .reader + .as_ref() + .ok_or_else(|| Error::InvalidRequest("OCR host has no document reader".into())) + .and_then(|reader| reader()) + .map(OcrOpResult::Document), + OcrOp::AcquireAzureAdToken => { + Err(Error::Auth(litellm_auth::Error::AzureTokenAcquisition( + "OCR host has no Azure AD token provider".into(), + ))) + } + } + } + + async fn before_send( + &self, + wire: WireRequest, + context: &RequestContext, + ) -> Result { + match &self.before_send { + Some(before_send) => before_send(wire, context), + None => Ok(wire), + } + } + + async fn emit(&self, event: &CallEvent) -> Result<(), Error> { + if let Some(observer) = &self.observer { + observer(event); + } + Ok(()) + } +} diff --git a/litellm-rust/crates/core/src/ocr/types.rs b/litellm-rust/crates/core/src/ocr/types.rs index fe7e41a6128..91851540c26 100644 --- a/litellm-rust/crates/core/src/ocr/types.rs +++ b/litellm-rust/crates/core/src/ocr/types.rs @@ -1,7 +1,4 @@ -use std::collections::BTreeMap; -use std::path::PathBuf; -use std::sync::Arc; -use std::time::Duration; +use std::{collections::BTreeMap, path::PathBuf, time::Duration}; use bytes::Bytes; use litellm_auth::{InputSource, Sourced, TokenProviderHandle}; @@ -9,11 +6,12 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use serde_with::serde_as; -use super::hooks::{NoopOcrHooks, OcrHooks}; use super::provider_config::{OcrConfigKind, resolve_provider_config}; -use crate::call_arguments::CallArguments; -use crate::constants::OCR_HTTP_TIMEOUT_SECS; -use crate::serde_compat::{FiniteF64, LaxI64}; +use crate::{ + call_arguments::CallArguments, + constants::OCR_HTTP_TIMEOUT_SECS, + serde_compat::{FiniteF64, LaxI64}, +}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "type")] @@ -275,8 +273,6 @@ pub struct LiteLLMOcrRequest { pub document: D, pub credentials: OcrCredentialInputs, pub transport: OcrTransportConfig, - pub hooks: Arc, - pub litellm_call_id: Option, pub optional_params: CallArguments, pub input_sources: BTreeMap, pub azure_ad_token_provider: Option, @@ -319,8 +315,6 @@ impl LiteLLMOcrRequest { document: document.into(), credentials: OcrCredentialInputs::default(), transport, - hooks: Arc::new(NoopOcrHooks), - litellm_call_id: None, optional_params, input_sources: BTreeMap::new(), azure_ad_token_provider: None, @@ -339,8 +333,6 @@ impl LiteLLMOcrRequest { document: map(self.document)?, credentials: self.credentials, transport: self.transport, - hooks: self.hooks, - litellm_call_id: self.litellm_call_id, optional_params: self.optional_params, input_sources: self.input_sources, azure_ad_token_provider: self.azure_ad_token_provider, @@ -354,8 +346,6 @@ impl LiteLLMOcrRequest { document, credentials: self.credentials, transport: self.transport, - hooks: self.hooks, - litellm_call_id: self.litellm_call_id, optional_params: self.optional_params, input_sources: self.input_sources, azure_ad_token_provider: self.azure_ad_token_provider, @@ -378,18 +368,6 @@ impl LiteLLMOcrRequest { self.config.provider().into() } - pub fn with_host_hooks( - self, - hooks: Arc, - litellm_call_id: Option, - ) -> Self { - Self { - hooks, - litellm_call_id, - ..self - } - } - pub fn with_connection_inputs( self, credentials: OcrCredentialInputs, @@ -442,7 +420,10 @@ pub(crate) struct PreparedOcrRequest { pub model: String, pub document: OcrDocument, pub connection: OcrConnection, - pub hooks: Arc, + pub host: super::route::OcrHost, + /// Whether the caller handed over the document as is, so the wire body's document + /// is the caller's own input rather than something the route prepared. + pub caller_document: bool, pub optional_params: CallArguments, pub input_sources: BTreeMap, pub azure_ad_token_provider: Option, @@ -450,14 +431,17 @@ pub(crate) struct PreparedOcrRequest { } impl PreparedOcrRequest { - pub(crate) fn new(request: ResolvedOcrRequest, connection: OcrConnection) -> Self { + pub(crate) fn new( + request: ResolvedOcrRequest, + connection: OcrConnection, + host: super::route::OcrHost, + caller_document: bool, + ) -> Self { let LiteLLMOcrRequest { model, document, credentials: _, transport: _, - hooks, - litellm_call_id: _, optional_params, input_sources, azure_ad_token_provider, @@ -467,7 +451,8 @@ impl PreparedOcrRequest { model, document, connection, - hooks, + host, + caller_document, optional_params, input_sources, azure_ad_token_provider, diff --git a/litellm-rust/crates/core/src/ocr/wire.rs b/litellm-rust/crates/core/src/ocr/wire.rs index b2f07caa754..603e455ace1 100644 --- a/litellm-rust/crates/core/src/ocr/wire.rs +++ b/litellm-rust/crates/core/src/ocr/wire.rs @@ -1,5 +1,4 @@ -use std::collections::BTreeMap; -use std::time::Duration; +use std::{collections::BTreeMap, time::Duration}; use litellm_auth::InputSource; use serde::Deserialize; @@ -105,10 +104,11 @@ pub fn decode_document(value: Value) -> Result { #[cfg(test)] mod tests { - use super::*; use rstest::rstest; use serde_json::json; + use super::*; + #[rstest] #[case::omitted(json!({"type":"document_url", "document_url":"https://example.com/a.pdf"}))] #[case::null(json!({"type":"document_url", "document_url":"https://example.com/a.pdf", "document_name":null}))] @@ -120,7 +120,7 @@ mod tests { #[rstest] #[case::non_object(json!([]), "document")] #[case::missing_type(json!({"document_url":"https://example.com/a.pdf"}), "document")] - #[case::unsupported_type(json!({"type":"text"}), "document")] + #[case::unsupported_type(json!({"type":"text"}), "type")] #[case::missing_document_url(json!({"type":"document_url"}), "Document URL")] #[case::missing_image_url(json!({"type":"image_url"}), "Document URL")] fn ocr_contract_malformed_document_is_bad_request( @@ -133,7 +133,7 @@ mod tests { Error::RequestField { .. } | Error::MissingDocumentUrl )); assert_eq!(error.http_status_code(), Some(400)); - assert!(error.to_string().contains(field)); + assert!(error.to_string().contains(field), "{error}"); } #[test] diff --git a/litellm-rust/crates/core/src/params.rs b/litellm-rust/crates/core/src/params.rs index bdeb178c940..9545a3ef17b 100644 --- a/litellm-rust/crates/core/src/params.rs +++ b/litellm-rust/crates/core/src/params.rs @@ -28,14 +28,6 @@ pub fn is_control_param(name: &str) -> bool { | "max_retries" | "req_format" | "max_response_bytes" - | "litellm_call_id" - | "litellm_logging_obj" - | "litellm_metadata" - | "proxy_server_request" - | "callbacks" - | "success_callback" - | "failure_callback" - | "guardrails" | "azure_ad_token" | "azure_ad_token_provider" | "tenant_id" diff --git a/litellm-rust/crates/core/src/responses/instrumentation.rs b/litellm-rust/crates/core/src/responses/instrumentation.rs deleted file mode 100644 index b1cf5ae09d8..00000000000 --- a/litellm-rust/crates/core/src/responses/instrumentation.rs +++ /dev/null @@ -1,366 +0,0 @@ -use std::future::Future; -use std::pin::Pin; -use std::sync::Mutex; -use std::time::{SystemTime, UNIX_EPOCH}; - -use serde_json::Value; - -use super::Error; -use crate::call_lifecycle::{CallLifecycleContext, CallLifecycleHooks, CallLifecycleTiming}; -use crate::responses::types::{ResponsesWsEvent, ResponsesWsEventType}; - -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct ResponsesWsUsage { - pub prompt_tokens: u64, - pub completion_tokens: u64, - pub total_tokens: u64, -} - -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct ResponsesWsMetadata { - pub user_api_key_hash: Option, - pub user_api_key_user_id: Option, - pub user_api_key_team_id: Option, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct ResponsesWsLogPayload { - pub id: String, - pub litellm_call_id: String, - pub call_type: String, - pub model: String, - pub custom_llm_provider: String, - pub response_cost: f64, - pub usage: ResponsesWsUsage, - pub start_time: f64, - pub end_time: f64, - pub stream: bool, - pub metadata: ResponsesWsMetadata, -} - -#[derive(Clone, Debug, PartialEq)] -pub enum ResponsesWsLogOutcome { - Success { - payload: ResponsesWsLogPayload, - callback: ResponsesWsCallbackPayload, - }, - Failure { - payload: ResponsesWsLogPayload, - callback: ResponsesWsCallbackPayload, - error_message: String, - error_kind: String, - }, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct ResponsesWsCallbackPayload { - pub object: String, - pub value: Value, -} - -struct InstrumentationState { - litellm_call_id: String, - id: String, - model: String, - usage: ResponsesWsUsage, - start_time: f64, - end_time: f64, - metadata: ResponsesWsMetadata, - outcome: Option, -} - -pub struct ResponsesWsInstrumentation { - state: Mutex, -} - -impl ResponsesWsInstrumentation { - pub fn new( - litellm_call_id: impl Into, - model: impl Into, - metadata: ResponsesWsMetadata, - ) -> Self { - let litellm_call_id = litellm_call_id.into(); - let now = epoch_seconds(); - Self { - state: Mutex::new(InstrumentationState { - id: litellm_call_id.clone(), - litellm_call_id, - model: model.into(), - usage: ResponsesWsUsage::default(), - start_time: now, - end_time: now, - metadata, - outcome: None, - }), - } - } - - pub fn observe(&self, event: &ResponsesWsEvent) { - if !matches!( - event.event_type, - ResponsesWsEventType::ResponseCreated - | ResponsesWsEventType::ResponseCompleted - | ResponsesWsEventType::ResponseFailed - | ResponsesWsEventType::ResponseIncomplete - | ResponsesWsEventType::Error - ) { - return; - } - let Ok(mut state) = self.state.lock() else { - return; - }; - let Some(response) = event.data.get("response").and_then(Value::as_object) else { - return; - }; - if let Some(id) = response - .get("id") - .and_then(Value::as_str) - .filter(|value| !value.is_empty()) - { - state.id = id.to_string(); - state.litellm_call_id = id.to_string(); - } - if let Some(model) = response - .get("model") - .and_then(Value::as_str) - .filter(|value| !value.is_empty()) - { - state.model = model.to_string(); - } - let Some(usage) = response.get("usage").and_then(Value::as_object) else { - return; - }; - if let Some(input) = usage.get("input_tokens").and_then(Value::as_u64) { - state.usage.prompt_tokens += input; - } - if let Some(output) = usage.get("output_tokens").and_then(Value::as_u64) { - state.usage.completion_tokens += output; - } - state.usage.total_tokens += usage - .get("total_tokens") - .and_then(Value::as_u64) - .unwrap_or_else(|| { - usage - .get("input_tokens") - .and_then(Value::as_u64) - .unwrap_or(0) - + usage - .get("output_tokens") - .and_then(Value::as_u64) - .unwrap_or(0) - }); - } - - pub fn success_outcome(&self) -> ResponsesWsLogOutcome { - let mut state = self - .state - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - state.end_time = epoch_seconds(); - ResponsesWsLogOutcome::Success { - payload: build_payload(&state), - callback: ResponsesWsCallbackPayload { - object: "responses_websocket".to_string(), - value: Value::Null, - }, - } - } - - pub fn failure_outcome(&self) -> ResponsesWsLogOutcome { - let mut state = self - .state - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - state.end_time = epoch_seconds(); - ResponsesWsLogOutcome::Failure { - payload: build_payload(&state), - callback: ResponsesWsCallbackPayload { - object: "error".to_string(), - value: serde_json::json!({ - "message": "Responses WebSocket session ended in failure", - "kind": "ResponsesWebSocketError", - }), - }, - error_message: "Responses WebSocket session ended in failure".to_string(), - error_kind: "ResponsesWebSocketError".to_string(), - } - } - - pub fn take_outcome(&self) -> Option { - self.state - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .outcome - .take() - } - - pub fn take_or_build_outcome(&self, success: bool) -> ResponsesWsLogOutcome { - self.take_outcome().unwrap_or_else(|| { - if success { - self.success_outcome() - } else { - self.failure_outcome() - } - }) - } -} - -type LifecycleFuture<'a, T> = Pin> + Send + 'a>>; - -impl CallLifecycleHooks<(), (), ()> for ResponsesWsInstrumentation { - type Error = Error; - type PreCallFuture<'a> = LifecycleFuture<'a, ()>; - type DuringCallFuture<'a> = LifecycleFuture<'a, ()>; - type SuccessFuture<'a> = Pin + Send + 'a>>; - type FailureFuture<'a> = Pin + Send + 'a>>; - - fn async_pre_call_hook<'a>( - &'a self, - _context: &'a CallLifecycleContext, - request: (), - ) -> Self::PreCallFuture<'a> { - Box::pin(async move { Ok(request) }) - } - - fn async_during_call_hook<'a>( - &'a self, - _context: &'a CallLifecycleContext, - request: (), - ) -> Self::DuringCallFuture<'a> { - Box::pin(async move { Ok(request) }) - } - - fn async_log_success_event<'a>( - &'a self, - _context: &'a CallLifecycleContext, - _response: &'a (), - _timing: &'a CallLifecycleTiming, - ) -> Self::SuccessFuture<'a> { - Box::pin(async move { - let outcome = self.success_outcome(); - if let Ok(mut state) = self.state.lock() { - state.outcome = Some(outcome); - } - }) - } - - fn async_log_failure_event<'a>( - &'a self, - _context: &'a CallLifecycleContext, - _error: &'a Error, - _timing: &'a CallLifecycleTiming, - ) -> Self::FailureFuture<'a> { - Box::pin(async move { - let outcome = self.failure_outcome(); - if let Ok(mut state) = self.state.lock() { - state.outcome = Some(outcome); - } - }) - } -} - -fn build_payload(state: &InstrumentationState) -> ResponsesWsLogPayload { - ResponsesWsLogPayload { - id: state.id.clone(), - litellm_call_id: state.litellm_call_id.clone(), - call_type: "responses_websocket".to_string(), - model: state.model.clone(), - custom_llm_provider: "openai".to_string(), - response_cost: 0.0, - usage: state.usage.clone(), - start_time: state.start_time, - end_time: state.end_time, - stream: true, - metadata: state.metadata.clone(), - } -} - -fn epoch_seconds() -> f64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_secs_f64()) - .unwrap_or(0.0) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn event(value: Value) -> ResponsesWsEvent { - serde_json::from_value(value).expect("valid Responses WebSocket event") - } - - #[test] - fn accumulates_upstream_usage_and_identity() { - let instrumentation = - ResponsesWsInstrumentation::new("call-1", "gpt-5", ResponsesWsMetadata::default()); - instrumentation.observe(&event(serde_json::json!({ - "type": "response.completed", - "response": { - "id": "resp-1", - "model": "gpt-5-mini", - "usage": { - "input_tokens": 3, - "output_tokens": 5, - "total_tokens": 8 - } - } - }))); - - let ResponsesWsLogOutcome::Success { payload, .. } = instrumentation.success_outcome() - else { - panic!("expected success outcome"); - }; - assert_eq!(payload.id, "resp-1"); - assert_eq!(payload.model, "gpt-5-mini"); - assert_eq!(payload.usage.prompt_tokens, 3); - assert_eq!(payload.usage.completion_tokens, 5); - assert_eq!(payload.usage.total_tokens, 8); - assert!(payload.end_time >= payload.start_time); - } - - #[test] - fn builds_failure_payload_without_dispatching_callbacks() { - let instrumentation = - ResponsesWsInstrumentation::new("call-1", "gpt-5", ResponsesWsMetadata::default()); - assert!(matches!( - instrumentation.failure_outcome(), - ResponsesWsLogOutcome::Failure { .. } - )); - } - - #[tokio::test] - async fn lifecycle_records_success_outcome_for_provider_completion() { - let instrumentation = - ResponsesWsInstrumentation::new("call-1", "gpt-5", ResponsesWsMetadata::default()); - let result = crate::call_lifecycle::CallLifecycle::default() - .run( - crate::call_lifecycle::CallLifecycleContext::new( - "responses_websocket", - "gpt-5", - "openai", - "call-1", - ), - (), - &instrumentation, - |_| async { Ok::<(), Error>(()) }, - ) - .await; - - assert!(result.is_ok()); - assert!(matches!( - instrumentation.take_outcome(), - Some(ResponsesWsLogOutcome::Success { .. }) - )); - } - - #[test] - fn builds_outcome_when_lifecycle_did_not_record_one() { - let instrumentation = - ResponsesWsInstrumentation::new("call-1", "gpt-5", ResponsesWsMetadata::default()); - assert!(matches!( - instrumentation.take_or_build_outcome(true), - ResponsesWsLogOutcome::Success { .. } - )); - } -} diff --git a/litellm-rust/crates/core/src/responses/mod.rs b/litellm-rust/crates/core/src/responses/mod.rs index f8b6d27ffab..6af2bf0c199 100644 --- a/litellm-rust/crates/core/src/responses/mod.rs +++ b/litellm-rust/crates/core/src/responses/mod.rs @@ -1,5 +1,4 @@ mod error; pub use error::Error; -pub mod instrumentation; pub mod types; pub mod websocket; diff --git a/litellm-rust/crates/core/src/responses/websocket.rs b/litellm-rust/crates/core/src/responses/websocket.rs index ab7738e81b9..7758cb2414c 100644 --- a/litellm-rust/crates/core/src/responses/websocket.rs +++ b/litellm-rust/crates/core/src/responses/websocket.rs @@ -1,24 +1,29 @@ -use std::collections::HashMap; -use std::io; -use std::sync::{Arc, OnceLock}; -use std::time::Duration; +use std::{ + collections::HashMap, + io, + sync::{Arc, OnceLock}, + time::Duration, +}; use futures_util::{SinkExt, StreamExt}; use rustls::{ClientConfig, RootCertStore}; -use tokio::net::TcpStream; -use tokio::sync::Mutex; -use tokio_tungstenite::tungstenite::Message; -use tokio_tungstenite::tungstenite::client::IntoClientRequest; -use tokio_tungstenite::tungstenite::error::TlsError; -use tokio_tungstenite::tungstenite::handshake::client::Response; -use tokio_tungstenite::tungstenite::http::{HeaderName, HeaderValue}; +use tokio::{net::TcpStream, sync::Mutex}; use tokio_tungstenite::{ Connector, MaybeTlsStream, WebSocketStream, connect_async_tls_with_config, + tungstenite::{ + Message, + client::IntoClientRequest, + error::TlsError, + handshake::client::Response, + http::{HeaderName, HeaderValue}, + }, }; use super::Error; -use crate::constants::{OPENAI_RESPONSES_DEFAULT_API_BASE, OPENAI_RESPONSES_PATH}; -use crate::responses::types::{ResponsesWsEvent, ResponsesWsEventType, ResponsesWsTransformResult}; +use crate::{ + constants::{OPENAI_RESPONSES_DEFAULT_API_BASE, OPENAI_RESPONSES_PATH}, + responses::types::{ResponsesWsEvent, ResponsesWsEventType, ResponsesWsTransformResult}, +}; pub trait ResponsesWebSocketProviderConfig: Sync { fn supports_native_websocket(&self) -> bool { diff --git a/litellm-rust/crates/core/tests/azure_ai_ocr.rs b/litellm-rust/crates/core/tests/azure_ai_ocr.rs index 253d2582acc..ad46abc9ccd 100644 --- a/litellm-rust/crates/core/tests/azure_ai_ocr.rs +++ b/litellm-rust/crates/core/tests/azure_ai_ocr.rs @@ -1,9 +1,9 @@ -use std::sync::Arc; - use serde_json::{Value, json}; -use super::hooks::{OcrDuringCallRequest, OcrHookFuture, OcrHooks}; -use super::test_support::{MockResponse, mock_server, perform_ocr, wire_request}; +use super::{ + LocalOcrHost, + test_support::{MockResponse, mock_server, perform_ocr, perform_ocr_with, wire_request}, +}; #[tokio::test] async fn facade_executes_azure_mistral_with_prepared_auth() { @@ -67,31 +67,16 @@ async fn facade_acquires_supplied_entra_token_for_final_request() { ); } -struct ReplaceBodyDocument; - -impl OcrHooks for ReplaceBodyDocument { - fn intercepts_requests(&self) -> bool { - true - } - - fn during_call( - &self, - mut request: OcrDuringCallRequest, - ) -> OcrHookFuture<'_, OcrDuringCallRequest> { - Box::pin(async move { - request.body["document"] = json!({ - "type":"document_url", - "document_url":"https://example.com/not-inline.pdf" - }); - Ok(request) - }) - } -} - #[tokio::test] async fn rejects_non_inline_body_after_guardrails() { - let mut request = wire_request("azure_ai/model", "http://127.0.0.1:1", json!({})); - request.hooks = Arc::new(ReplaceBodyDocument); - let error = perform_ocr(request).await.unwrap_err(); + let request = wire_request("azure_ai/model", "http://127.0.0.1:1", json!({})); + let host = LocalOcrHost::new(request).with_before_send(|mut wire, _| { + wire.body["document"] = json!({ + "type":"document_url", + "document_url":"https://example.com/not-inline.pdf" + }); + Ok(wire) + }); + let error = perform_ocr_with(host).await.unwrap_err(); assert!(error.to_string().contains("data URI")); } diff --git a/litellm-rust/crates/core/tests/azure_document_intelligence_ocr.rs b/litellm-rust/crates/core/tests/azure_document_intelligence_ocr.rs index 41fe0c734cf..6039ee2bfe4 100644 --- a/litellm-rust/crates/core/tests/azure_document_intelligence_ocr.rs +++ b/litellm-rust/crates/core/tests/azure_document_intelligence_ocr.rs @@ -1,10 +1,12 @@ -use std::sync::{Arc, Mutex}; - +use litellm_callbacks::event::CallEvent; use rstest::rstest; use serde_json::{Value, json}; -use super::test_support::{MockResponse, mock_server, perform_ocr, wire_request}; -use super::wire::{OcrWireRequest, decode_request}; +use super::{ + LocalOcrHost, + test_support::{MockResponse, mock_server, perform_ocr, perform_ocr_with, wire_request}, + wire::{OcrWireRequest, decode_request}, +}; fn query_value(url: &str, key: &str) -> Option { url::Url::parse(url) @@ -241,34 +243,8 @@ async fn accepted_response_polls_to_success_with_only_credentials() { } } -struct SubmissionBoundary { - request_count: Arc>>, -} - -impl super::hooks::OcrHooks for SubmissionBoundary { - fn post_call( - &self, - request: super::hooks::OcrPostCallRequest, - ) -> super::hooks::OcrHookFuture<'_, super::hooks::OcrPostCallRequest> { - Box::pin(async move { - match self.request_count.lock().unwrap().len() { - 1 => assert_eq!(request.original_response, json!(r#"{"submitted":true}"#)), - 2 => assert!( - request - .original_response - .as_str() - .unwrap() - .contains("succeeded") - ), - count => panic!("unexpected callback after {count} requests"), - } - Ok(request) - }) - } -} - #[tokio::test] -async fn accepted_response_runs_post_call_before_polling() { +async fn accepted_response_emits_response_received_before_polling() { let (base, seen, server) = mock_server(vec![ MockResponse { status: 202, @@ -278,14 +254,24 @@ async fn accepted_response_runs_post_call_before_polling() { MockResponse::json(json!({"status":"succeeded"})), ]) .await; - let request = super::LiteLLMOcrRequest { - hooks: Arc::new(SubmissionBoundary { - request_count: seen.clone(), - }), - ..wire_request("azure_ai/doc-intelligence/prebuilt-read", &base, json!({})) - }; + let request_count = seen.clone(); + let host = LocalOcrHost::new(wire_request( + "azure_ai/doc-intelligence/prebuilt-read", + &base, + json!({}), + )) + .with_observer(move |event| { + let CallEvent::ResponseReceived { raw } = event else { + return; + }; + match request_count.lock().unwrap().len() { + 1 => assert_eq!(raw.body, r#"{"submitted":true}"#), + 2 => assert!(raw.body.contains("succeeded")), + count => panic!("unexpected callback after {count} requests"), + } + }); - perform_ocr(request).await.unwrap(); + perform_ocr_with(host).await.unwrap(); server.await.unwrap(); assert_eq!(seen.lock().unwrap().len(), 2); } @@ -474,44 +460,3 @@ async fn model_id_is_encoded_and_dot_segments_are_rejected() { assert!(error.to_string().contains("dot segment")); } } - -#[tokio::test] -async fn pre_call_guardrail_receives_caller_pages_before_mapping() { - use std::sync::Arc; - - use crate::ocr::hooks::{OcrHookFuture, OcrHooks, OcrPreCallRequest}; - - struct RewritePages; - impl OcrHooks for RewritePages { - fn intercepts_requests(&self) -> bool { - true - } - - fn pre_call(&self, request: OcrPreCallRequest) -> OcrHookFuture<'_, OcrPreCallRequest> { - Box::pin(async move { - assert_eq!(request.optional_params["pages"], json!([0, 2])); - Ok(OcrPreCallRequest { - optional_params: json!({"pages": [1]}), - ..request - }) - }) - } - } - let (base, seen, server) = - mock_server(vec![MockResponse::json(json!({"status": "succeeded"}))]).await; - let request = wire_request( - "azure_ai/doc-intelligence/prebuilt-read", - &base, - json!({"pages": [0, 2]}), - ) - .with_host_hooks(Arc::new(RewritePages), None); - perform_ocr(request).await.unwrap(); - server.await.unwrap(); - let requests = seen.lock().unwrap(); - let target = requests[0].split_whitespace().nth(1).unwrap(); - assert_eq!( - query_value(&format!("{base}{target}"), "pages").as_deref(), - Some("2") - ); - assert_eq!(requests.len(), 1); -} diff --git a/litellm-rust/crates/core/tests/deepseek_ocr.rs b/litellm-rust/crates/core/tests/deepseek_ocr.rs index 3129f1e60a9..491978df75a 100644 --- a/litellm-rust/crates/core/tests/deepseek_ocr.rs +++ b/litellm-rust/crates/core/tests/deepseek_ocr.rs @@ -1,12 +1,16 @@ use rstest::rstest; use serde_json::{Value, json}; -use crate::llms::base_llm::ocr::transformation::BaseOcrConfig; -use crate::llms::vertex_ai::ocr::deepseek_transformation::{ - DeepSeekOcrParams, DeepSeekOcrResponse, VertexAIDeepSeekOCRConfig, - normalize_response as transform_ocr_response, +use crate::{ + llms::{ + base_llm::ocr::transformation::BaseOcrConfig, + vertex_ai::ocr::deepseek_transformation::{ + DeepSeekOcrParams, DeepSeekOcrResponse, VertexAIDeepSeekOCRConfig, + normalize_response as transform_ocr_response, + }, + }, + ocr::types::OcrDocument, }; -use crate::ocr::types::OcrDocument; fn document() -> OcrDocument { serde_json::from_value(json!({"type":"image_url","image_url":"gs://bucket/a.png"})).unwrap() diff --git a/litellm-rust/crates/core/tests/host_lifecycle.rs b/litellm-rust/crates/core/tests/host_lifecycle.rs deleted file mode 100644 index cdf9a7a2c8a..00000000000 --- a/litellm-rust/crates/core/tests/host_lifecycle.rs +++ /dev/null @@ -1,117 +0,0 @@ -use crate::call_lifecycle::host::{HostFailure, HostLifecycle, HostPhase}; -use crate::ocr::Error; - -fn run(fail_at: Option, asynchronous: bool) -> (Vec, Vec) { - let mut lifecycle = HostLifecycle::new(asynchronous); - let mut events = Vec::new(); - let mut failures = Vec::new(); - while lifecycle.phase() != HostPhase::Complete { - let phase = lifecycle.phase(); - events.push(phase); - let result = if Some(phase) == fail_at { - Err(HostFailure::Error(Error::InvalidRequest( - "selected failure".into(), - ))) - } else { - Ok(()) - }; - if let Some(error) = lifecycle.accept(result) { - failures.push(error); - } - } - (events, failures) -} - -#[test] -fn public_outcome_is_finalized_before_a_single_terminal_dispatch() { - for asynchronous in [false, true] { - let (events, failures) = run(None, asynchronous); - assert!(failures.is_empty()); - assert_eq!( - &events[events.len() - 2..], - &[HostPhase::Finalize, HostPhase::Success] - ); - assert_eq!( - events - .iter() - .filter(|phase| **phase == HostPhase::Execute) - .count(), - 1 - ); - assert_eq!( - events.contains(&HostPhase::DeploymentPostCall), - asynchronous - ); - } -} - -#[test] -fn only_provider_and_response_construction_failures_use_provider_mapping() { - for phase in [ - HostPhase::Setup, - HostPhase::DeploymentPreCall, - HostPhase::Prepare, - HostPhase::Execute, - HostPhase::ConstructResponse, - HostPhase::DeploymentPostCall, - HostPhase::Finalize, - ] { - let (events, failures) = run(Some(phase), true); - assert_eq!(failures.len(), 1); - assert!(!events.contains(&HostPhase::Success)); - let mapped = matches!(phase, HostPhase::Execute | HostPhase::ConstructResponse); - assert_eq!(events.contains(&HostPhase::MapFailure), mapped); - assert_eq!(events.contains(&HostPhase::DeploymentFailure), mapped); - assert_eq!( - &events[events.len() - 2..], - &[HostPhase::Failure, HostPhase::AsyncFailure] - ); - assert!( - events - .iter() - .filter(|phase| **phase == HostPhase::Execute) - .count() - <= 1 - ); - } -} - -#[test] -fn failure_handler_errors_do_not_replace_selected_failure_or_suppress_async_dispatch() { - let mut lifecycle = HostLifecycle::new(true); - while lifecycle.phase() != HostPhase::Execute { - lifecycle.accept::(Ok(())); - } - let selected = Error::InvalidRequest("provider".into()); - assert!(matches!( - lifecycle.accept(Err(HostFailure::Error(selected.clone()))), - Some(Error::InvalidRequest(message)) if message == "provider" - )); - lifecycle.accept::(Ok(())); - for phase in [ - HostPhase::DeploymentFailure, - HostPhase::Failure, - HostPhase::AsyncFailure, - ] { - assert_eq!(lifecycle.phase(), phase); - assert!( - lifecycle - .accept(Err(HostFailure::Error(Error::InvalidRequest( - "callback".into() - )))) - .is_none() - ); - } - assert_eq!(lifecycle.phase(), HostPhase::Complete); -} - -#[test] -fn cancellation_skips_terminal_dispatch() { - let mut lifecycle = HostLifecycle::new(true); - let error = Error::InvalidRequest("cancelled".into()); - assert!(matches!( - lifecycle.accept(Err(HostFailure::Cancelled(error.clone()))), - Some(Error::InvalidRequest(message)) if message == "cancelled" - )); - assert_eq!(lifecycle.phase(), HostPhase::Complete); -} diff --git a/litellm-rust/crates/core/tests/ocr.rs b/litellm-rust/crates/core/tests/ocr.rs index 58762fb4d93..af88c5f6ec9 100644 --- a/litellm-rust/crates/core/tests/ocr.rs +++ b/litellm-rust/crates/core/tests/ocr.rs @@ -1,20 +1,20 @@ use std::sync::{Arc, Mutex}; +use litellm_callbacks::{ + event::{CallEvent, WireRequest}, + host::{Host, HostOp, HostResult}, + machine::{HostFailure, Machine, MachineStep}, +}; use rstest::rstest; use serde_json::{Value, json}; -use super::OcrClient; -use super::hooks::{ - OcrDuringCallRequest, OcrHookFuture, OcrHooks, OcrLogFuture, OcrPostCallRequest, - OcrPreCallRequest, -}; -use super::test_support::{MockResponse, mock_server, perform_ocr, wire_request}; -use super::wire::{OcrWireRequest, decode_request}; use super::{ - NativeOutcome, NoopOcrHost, OcrAdmission, OcrCall, OcrCallStep, OcrDecline, OcrHost, - OcrHostOperation, OcrHostResult, + LocalOcrHost, OcrClient, OcrOp, OcrOpResult, ocr_machine, + test_support::{ + MockResponse, mock_server, ocr_client, perform_ocr, perform_ocr_with, wire_request, + }, + wire::{OcrWireRequest, decode_request}, }; -use crate::call_lifecycle::{CallLifecycleContext, CallLifecycleTiming}; #[rstest] #[case::mistral("mistral/model", json!({}))] @@ -184,115 +184,111 @@ async fn facade_uses_the_injected_http_client() { assert!(seen.lock().unwrap()[0].contains("x-transport-owner: host")); } -struct RecordingHooks { +fn event_name(event: &CallEvent) -> &'static str { + match event { + CallEvent::ResponseReceived { .. } => "response", + CallEvent::Succeeded { .. } => "success", + CallEvent::Failed { .. } => "failure", + } +} + +fn recording_host( + request: super::LiteLLMOcrRequest, events: Arc>>, block: bool, -} - -impl OcrHooks for RecordingHooks { - fn intercepts_requests(&self) -> bool { - true - } - - fn pre_call(&self, request: OcrPreCallRequest) -> OcrHookFuture<'_, OcrPreCallRequest> { - Box::pin(async move { - self.events.lock().unwrap().push("pre"); - if self.block { +) -> LocalOcrHost { + let before_send_events = events.clone(); + LocalOcrHost::new(request) + .with_before_send(move |wire, _| { + before_send_events.lock().unwrap().push("before_send"); + if block { return Err(crate::ocr::Error::InvalidRequest("blocked".into())); } - Ok(request) + Ok(wire) }) - } - - fn during_call( - &self, - request: super::hooks::OcrDuringCallRequest, - ) -> OcrHookFuture<'_, super::hooks::OcrDuringCallRequest> { - Box::pin(async move { - self.events.lock().unwrap().push("during"); - Ok(request) - }) - } - - fn post_call(&self, request: OcrPostCallRequest) -> OcrHookFuture<'_, OcrPostCallRequest> { - Box::pin(async move { - self.events.lock().unwrap().push("post"); - Ok(request) - }) - } - - fn success<'a>( - &'a self, - _context: &'a CallLifecycleContext, - _response: &'a super::LiteLLMOcrResponse, - _timing: &'a CallLifecycleTiming, - ) -> OcrLogFuture<'a> { - Box::pin(async move { - self.events.lock().unwrap().push("success"); - }) - } - - fn failure<'a>( - &'a self, - _context: &'a CallLifecycleContext, - _error: &'a crate::ocr::Error, - _timing: &'a CallLifecycleTiming, - ) -> OcrLogFuture<'a> { - Box::pin(async move { - self.events.lock().unwrap().push("failure"); - }) - } -} - -struct HeaderEditHooks; - -impl OcrHooks for HeaderEditHooks { - fn intercepts_requests(&self) -> bool { - true - } - - fn during_call( - &self, - mut request: OcrDuringCallRequest, - ) -> OcrHookFuture<'_, OcrDuringCallRequest> { - request - .headers - .push(("x-core-callback".into(), "edited".into())); - Box::pin(async move { Ok(request) }) - } + .with_observer(move |event| events.lock().unwrap().push(event_name(event))) } #[tokio::test] -async fn lifecycle_sends_headers_returned_by_the_typed_during_call_operation() { +async fn lifecycle_sends_headers_returned_by_the_before_send_operation() { let (base, seen, server) = mock_server(vec![MockResponse::json(json!({"pages":[]}))]).await; - let request = super::LiteLLMOcrRequest { - hooks: Arc::new(HeaderEditHooks), - ..wire_request("mistral/model", &base, json!({})) - }; + let host = LocalOcrHost::new(wire_request("mistral/model", &base, json!({}))).with_before_send( + |mut wire, _| { + wire.headers + .push(("x-core-callback".into(), "edited".into())); + Ok(wire) + }, + ); - perform_ocr(request).await.unwrap(); + perform_ocr_with(host).await.unwrap(); server.await.unwrap(); assert!(seen.lock().unwrap()[0].contains("x-core-callback: edited")); } +#[tokio::test] +async fn before_send_context_names_passthrough_fields_and_secrets() { + let (base, _, server) = mock_server(vec![MockResponse::json(json!({"pages":[]}))]).await; + let observed = Arc::new(Mutex::new(None)); + let captured = observed.clone(); + let host = LocalOcrHost::new(wire_request( + "mistral/model", + &base, + json!({"pages": [0], "req_format": "native"}), + )) + .with_before_send(move |wire, context| { + *captured.lock().unwrap() = Some((wire.clone(), context.clone())); + Ok(wire) + }); + perform_ocr_with(host).await.unwrap(); + server.await.unwrap(); + let (wire, context) = observed.lock().unwrap().take().unwrap(); + assert_eq!(context.custom_llm_provider, "mistral"); + assert_eq!(context.model, "model"); + assert_eq!(wire.body["pages"], json!([0])); + assert!(context.passthrough_fields.contains("pages")); + assert!(context.passthrough_fields.contains("document")); + assert!(context.secret_fields.is_empty()); + assert_eq!(context.optional_params["req_format"], "native"); + + let (base, _, server) = mock_server(vec![MockResponse::json(json!({"pages":[]}))]).await; + let observed = Arc::new(Mutex::new(None)); + let captured = observed.clone(); + let request = wire_request( + "azure_ai/model", + &base, + json!({"client_secret": "shh", "tenant_id": "t"}), + ); + let request = request.with_document(super::OcrDocumentInput::Bytes { + bytes: b"abc".as_slice().into(), + file_name: None, + mime_type: Some("application/pdf".into()), + }); + let host = LocalOcrHost::new(request).with_before_send(move |wire, context| { + *captured.lock().unwrap() = Some(context.clone()); + Ok(wire) + }); + perform_ocr_with(host).await.unwrap(); + server.await.unwrap(); + let context = observed.lock().unwrap().take().unwrap(); + assert!(!context.passthrough_fields.contains("document")); + assert_eq!(context.secret_fields, ["client_secret"]); +} + #[tokio::test] async fn lifecycle_orders_hooks_and_emits_one_success() { let (base, seen, server) = mock_server(vec![MockResponse::json(json!({"pages":[]}))]).await; let events = Arc::new(Mutex::new(Vec::new())); - let request = wire_request("mistral/model", &base, json!({})); - let request = super::LiteLLMOcrRequest { - hooks: Arc::new(RecordingHooks { - events: events.clone(), - block: false, - }), - ..request - }; - perform_ocr(request).await.unwrap(); + let host = recording_host( + wire_request("mistral/model", &base, json!({})), + events.clone(), + false, + ); + perform_ocr_with(host).await.unwrap(); server.await.unwrap(); assert_eq!( *events.lock().unwrap(), - ["pre", "during", "post", "success"] + ["before_send", "response", "success"] ); assert_eq!(seen.lock().unwrap().len(), 1); } @@ -300,17 +296,14 @@ async fn lifecycle_orders_hooks_and_emits_one_success() { #[tokio::test] async fn lifecycle_blocking_prevents_execution_and_emits_one_failure() { let events = Arc::new(Mutex::new(Vec::new())); - let request = wire_request("mistral/model", "http://127.0.0.1:1", json!({})); - let request = super::LiteLLMOcrRequest { - hooks: Arc::new(RecordingHooks { - events: events.clone(), - block: true, - }), - ..request - }; - let error = perform_ocr(request).await.unwrap_err(); - assert!(matches!(error, crate::ocr::Error::InvalidRequest(_))); - assert_eq!(*events.lock().unwrap(), ["pre", "failure"]); + let host = recording_host( + wire_request("mistral/model", "http://127.0.0.1:1", json!({})), + events.clone(), + true, + ); + let error = perform_ocr_with(host).await.unwrap_err(); + assert!(matches!(error, crate::ocr::Error::InvalidRequest(message) if message == "blocked")); + assert_eq!(*events.lock().unwrap(), ["before_send", "failure"]); } #[tokio::test] @@ -322,166 +315,110 @@ async fn upstream_failure_emits_one_terminal_failure() { }]) .await; let events = Arc::new(Mutex::new(Vec::new())); - let request = wire_request("mistral/model", &base, json!({})); - let request = super::LiteLLMOcrRequest { - hooks: Arc::new(RecordingHooks { - events: events.clone(), - block: false, - }), - ..request - }; - assert!(perform_ocr(request).await.is_err()); + let host = recording_host( + wire_request("mistral/model", &base, json!({})), + events.clone(), + false, + ); + assert!(perform_ocr_with(host).await.is_err()); server.await.unwrap(); - assert_eq!(*events.lock().unwrap(), ["pre", "during", "failure"]); + assert_eq!(*events.lock().unwrap(), ["before_send", "failure"]); assert_eq!(seen.lock().unwrap().len(), 1); } -struct AdmissionSpy { - effects: Arc>, -} - -impl OcrHooks for AdmissionSpy { - fn intercepts_requests(&self) -> bool { - *self.effects.lock().unwrap() += 1; - true - } - - fn pre_call(&self, request: OcrPreCallRequest) -> OcrHookFuture<'_, OcrPreCallRequest> { - *self.effects.lock().unwrap() += 1; - Box::pin(async move { Ok(request) }) - } -} - -#[test] -fn admission_declines_without_invoking_hooks_or_transport() { - for (admission, expected) in [ - ( - OcrAdmission { - provider_workflow: false, - host_operations: true, - asynchronous: false, - }, - OcrDecline::ProviderWorkflow, - ), - ( - OcrAdmission { - provider_workflow: true, - host_operations: false, - asynchronous: false, - }, - OcrDecline::HostOperations, - ), - ] { - let outcome = OcrCall::admit(super::test_support::ocr_client(), admission); - assert!(matches!(outcome, NativeOutcome::Declined(reason) if reason == expected)); - } -} - -#[tokio::test] -async fn fallible_host_phases_do_not_replay_or_reach_transport() { - for failure_phase in ["pre", "during"] { - let request = super::LiteLLMOcrRequest { - hooks: Arc::new(AdmissionSpy { - effects: Arc::new(Mutex::new(0)), - }), - ..wire_request("mistral/model", "http://127.0.0.1:1", json!({})) - }; - let NativeOutcome::Completed(mut call) = - OcrCall::admit(super::test_support::ocr_client(), OcrAdmission::all()) - else { - panic!("supported call declined") - }; - let mut request = Some(request); - let mut result = None; - let mut phases = Vec::new(); - let error = loop { - match call.resume(result.take()).await { - Ok(OcrCallStep::Host(operation)) => match operation { - OcrHostOperation::Lifecycle(_) - | OcrHostOperation::ConstructResponse(_) - | OcrHostOperation::MapFailure(_) - | OcrHostOperation::Success { .. } - | OcrHostOperation::Failure { .. } => { - result = Some(OcrHostResult::Lifecycle(Ok(()))) - } - OcrHostOperation::ProjectRequest => { - result = Some(OcrHostResult::Request(Ok(( - Box::new(request.take().unwrap()), - false, - )))) - } - OcrHostOperation::AcquireAzureAdToken => { - panic!("test request has no token provider") - } - OcrHostOperation::ReadDocument => panic!("test request has no file reader"), - OcrHostOperation::PreCall(request) => { - phases.push("pre"); - result = Some(OcrHostResult::PreCall(if failure_phase == "pre" { - Err(crate::ocr::Error::InvalidRequest("pre failed".into())) - } else { - Ok(request) - })); - } - OcrHostOperation::DuringCall(request) => { - phases.push("during"); - result = Some(OcrHostResult::DuringCall(if failure_phase == "during" { - Err(crate::ocr::Error::InvalidRequest("during failed".into())) - } else { - Ok(request) - })); - } - OcrHostOperation::PostCall(_) => panic!("transport should not be reached"), - }, - Err(error) => break error, - Ok(OcrCallStep::Complete(_)) => panic!("failed call completed"), - } - }; - assert!(matches!(error, crate::ocr::Error::InvalidRequest(_))); - assert_eq!( - phases - .iter() - .filter(|phase| **phase == failure_phase) - .count(), - 1 - ); - } -} - -#[tokio::test] -async fn invalid_provider_response_runs_post_call_before_normalization_failure() { - let (base, seen, server) = - mock_server(vec![MockResponse::json(json!({"pages":"invalid"}))]).await; - let mut request = Some(wire_request("mistral/model", &base, json!({}))); - let NativeOutcome::Completed(mut call) = - OcrCall::admit(super::test_support::ocr_client(), OcrAdmission::all()) - else { - panic!("supported call declined") - }; - let host = NoopOcrHost; +/// Drives the machine by hand, answering every op through `host` except `before_send`, +/// which `intercept` answers so a test can fail or cancel exactly there. +async fn drive_until( + client: OcrClient, + host: &LocalOcrHost, + mut intercept: impl FnMut(WireRequest) -> Result>, +) -> ( + Result, + Vec<&'static str>, + super::OcrMachine, +) { + let mut machine = ocr_machine(client); let mut result = None; - let mut post_calls = Vec::new(); - let error = loop { - match call.resume(result.take()).await { - Ok(OcrCallStep::Host(OcrHostOperation::ProjectRequest)) => { - result = Some(OcrHostResult::Request(Ok(( - Box::new(request.take().unwrap()), - false, - )))); + let mut ops = Vec::new(); + let outcome = loop { + let op = match machine.resume(result.take()).await { + Ok(MachineStep::Host(op)) => op, + Ok(MachineStep::Complete(response)) => break Ok(response), + Err(error) => break Err(error), + }; + let answer = match op { + HostOp::Route(op) => { + ops.push(match op { + OcrOp::ProjectRequest => "ProjectRequest", + OcrOp::ReadDocument => "ReadDocument", + OcrOp::AcquireAzureAdToken => "AcquireAzureAdToken", + }); + host.route(op) + .await + .map(HostResult::Route) + .map_err(HostFailure::Error) } - Ok(OcrCallStep::Host(operation)) => { - if let OcrHostOperation::PostCall(request) = &operation { - post_calls.push(request.original_response.clone()); - } - result = Some(host.invoke(operation).await); + HostOp::BeforeSend { wire, .. } => { + ops.push("BeforeSend"); + intercept(*wire).map(|wire| HostResult::BeforeSend(Box::new(wire))) } - Err(error) => break error, - Ok(OcrCallStep::Complete(_)) => panic!("invalid provider response completed"), + HostOp::Emit(event) => { + ops.push(event_name(&event)); + host.emit(&event) + .await + .map(|()| HostResult::Emitted) + .map_err(HostFailure::Error) + } + }; + match answer { + Ok(answer) => result = Some(answer), + Err(failure) => break machine.interrupt(failure).await, } }; + (outcome, ops, machine) +} + +#[tokio::test] +async fn failed_before_send_does_not_replay_or_reach_transport() { + let host = LocalOcrHost::new(wire_request( + "mistral/model", + "http://127.0.0.1:1", + json!({}), + )); + let (outcome, ops, mut machine) = drive_until(ocr_client(), &host, |_| { + Err(HostFailure::Error(crate::ocr::Error::InvalidRequest( + "before_send failed".into(), + ))) + }) + .await; + assert!( + matches!(outcome, Err(crate::ocr::Error::InvalidRequest(message)) if message == "before_send failed") + ); + assert_eq!(ops, ["ProjectRequest", "BeforeSend"]); + assert!(machine.resume(None).await.is_err()); +} + +#[tokio::test] +async fn invalid_provider_response_emits_response_received_before_normalization_failure() { + let (base, seen, server) = + mock_server(vec![MockResponse::json(json!({"pages":"invalid"}))]).await; + let responses_received = Arc::new(Mutex::new(Vec::new())); + let observed = responses_received.clone(); + let host = LocalOcrHost::new(wire_request("mistral/model", &base, json!({}))).with_observer( + move |event| { + if let CallEvent::ResponseReceived { raw } = event { + observed.lock().unwrap().push(raw.body.clone()); + } + }, + ); + let error = perform_ocr_with(host).await.unwrap_err(); server.await.unwrap(); assert!(matches!(error, crate::ocr::Error::ResponseField { .. })); assert_eq!(seen.lock().unwrap().len(), 1); - assert_eq!(post_calls, [json!(r#"{"pages":"invalid"}"#)]); + assert_eq!( + *responses_received.lock().unwrap(), + [r#"{"pages":"invalid"}"#] + ); } #[tokio::test] @@ -490,72 +427,14 @@ async fn direct_native_host_drives_the_same_state_machine() { "pages":[{"index":0,"markdown":"native"}] }))]) .await; - let request = super::LiteLLMOcrRequest { - hooks: Arc::new(AdmissionSpy { - effects: Arc::new(Mutex::new(0)), - }), - ..wire_request("mistral/model", &base, json!({})) - }; - let NativeOutcome::Completed(mut call) = OcrCall::admit( - super::test_support::ocr_client(), - OcrAdmission { - asynchronous: true, - ..OcrAdmission::all() - }, - ) else { - panic!("supported call declined") - }; - let mut request = Some(request); - let host = NoopOcrHost; - let mut result = None; - let mut operations = Vec::new(); - let response = loop { - match call.resume(result.take()).await.unwrap() { - OcrCallStep::Host(operation) => { - operations.push(match &operation { - OcrHostOperation::ProjectRequest => "ProjectRequest".into(), - OcrHostOperation::Lifecycle(phase) => format!("{phase:?}"), - OcrHostOperation::PreCall(_) => "PreCall".into(), - OcrHostOperation::DuringCall(_) => "DuringCall".into(), - OcrHostOperation::PostCall(_) => "PostCall".into(), - OcrHostOperation::ConstructResponse(_) => "ConstructResponse".into(), - OcrHostOperation::Success { response, .. } => { - assert_eq!(response.pages[0].markdown, "native"); - "Success".into() - } - _ => panic!("unexpected OCR operation"), - }); - result = Some(match operation { - OcrHostOperation::ProjectRequest => { - OcrHostResult::Request(Ok((Box::new(request.take().unwrap()), false))) - } - operation => host.invoke(operation).await, - }); - } - OcrCallStep::Complete(response) => break response, - } - }; + let host = LocalOcrHost::new(wire_request("mistral/model", &base, json!({}))); + let (outcome, ops, mut machine) = drive_until(ocr_client(), &host, Ok).await; server.await.unwrap(); - assert_eq!(response.pages[0].markdown, "native"); + assert_eq!(outcome.unwrap().pages[0].markdown, "native"); assert_eq!(seen.lock().unwrap().len(), 1); - assert_eq!( - operations, - [ - "Setup", - "DeploymentPreCall", - "Prepare", - "ProjectRequest", - "PreCall", - "DuringCall", - "PostCall", - "ConstructResponse", - "DeploymentPostCall", - "Finalize", - "Success", - ] - ); + assert_eq!(ops, ["ProjectRequest", "BeforeSend", "response"]); assert!(matches!( - call.resume(None).await, + machine.resume(None).await, Err(crate::ocr::Error::InvalidRequest(_)) )); } @@ -564,32 +443,15 @@ async fn drive_native_file_call( request: super::LiteLLMOcrRequest, content: Result, ) -> (Result, usize) { - let NativeOutcome::Completed(mut call) = - OcrCall::admit(super::test_support::ocr_client(), OcrAdmission::all()) - else { - panic!("supported call declined") - }; - let mut request = Some(request); - let mut content = Some(content); - let mut result = None; - let mut reads = 0; - let outcome = loop { - match call.resume(result.take()).await { - Ok(OcrCallStep::Host(OcrHostOperation::ProjectRequest)) => { - result = Some(OcrHostResult::Request(Ok(( - Box::new(request.take().unwrap()), - false, - )))); - } - Ok(OcrCallStep::Host(OcrHostOperation::ReadDocument)) => { - reads += 1; - result = Some(OcrHostResult::Document(content.take().unwrap())); - } - Ok(OcrCallStep::Host(operation)) => result = Some(NoopOcrHost.invoke(operation).await), - Ok(OcrCallStep::Complete(response)) => break Ok(response), - Err(error) => break Err(error), - } - }; + let reads = Arc::new(Mutex::new(0)); + let counted = reads.clone(); + let content = Mutex::new(Some(content)); + let host = LocalOcrHost::new(request).with_reader(move || { + *counted.lock().unwrap() += 1; + content.lock().unwrap().take().unwrap() + }); + let outcome = perform_ocr_with(host).await; + let reads = *reads.lock().unwrap(); (outcome, reads) } @@ -694,209 +556,43 @@ async fn path_documents_are_read_by_core_without_a_host_operation() { } #[tokio::test] -async fn public_finalization_failure_never_dispatches_success_or_replays_provider() { - use crate::call_lifecycle::host::{HostFailure, HostPhase}; - - let (base, seen, server) = mock_server(vec![MockResponse::json(json!({"pages":[]}))]).await; - let mut request = Some(wire_request("mistral/model", &base, json!({}))); - let NativeOutcome::Completed(mut call) = OcrCall::admit( - super::test_support::ocr_client(), - OcrAdmission { - asynchronous: true, - ..OcrAdmission::all() - }, - ) else { - panic!("supported call declined") - }; - let selected = crate::ocr::Error::InvalidRequest("public metadata failed".into()); - let host = NoopOcrHost; - let mut result = None; - let mut failures = Vec::new(); - let error = loop { - match call.resume(result.take()).await { - Ok(OcrCallStep::Host(operation)) => { - result = Some(match operation { - OcrHostOperation::Lifecycle(HostPhase::Finalize) => { - OcrHostResult::Lifecycle(Err(HostFailure::Error(selected.clone()))) - } - OcrHostOperation::Failure { error, .. } => { - assert!( - matches!(error, crate::ocr::Error::InvalidRequest(message) if message == "public metadata failed") - ); - failures.push("sync"); - OcrHostResult::Lifecycle(Err(HostFailure::Error( - crate::ocr::Error::InvalidRequest("failure callback failed".into()), - ))) - } - OcrHostOperation::Lifecycle(HostPhase::AsyncFailure) => { - failures.push("async"); - OcrHostResult::Lifecycle(Ok(())) - } - OcrHostOperation::Success { .. } - | OcrHostOperation::MapFailure(_) - | OcrHostOperation::Lifecycle(HostPhase::DeploymentFailure) => { - panic!("finalization failure used provider/success dispatch") - } - OcrHostOperation::ProjectRequest => { - OcrHostResult::Request(Ok((Box::new(request.take().unwrap()), false))) - } - operation => host.invoke(operation).await, - }); - } - Ok(OcrCallStep::Complete(_)) => panic!("failed call completed successfully"), - Err(error) => break error, - } - }; - server.await.unwrap(); - assert!( - matches!(error, crate::ocr::Error::InvalidRequest(message) if message == "public metadata failed") - ); - assert_eq!(failures, ["sync", "async"]); - assert_eq!(seen.lock().unwrap().len(), 1); -} - -#[tokio::test] -async fn cancellation_at_provider_hook_prevents_execution_and_further_resumption() { - use crate::call_lifecycle::host::HostFailure; - - let request = super::LiteLLMOcrRequest { - hooks: Arc::new(AdmissionSpy { - effects: Arc::new(Mutex::new(0)), - }), - ..wire_request("mistral/model", "http://127.0.0.1:1", json!({})) - }; - let NativeOutcome::Completed(mut call) = - OcrCall::admit(super::test_support::ocr_client(), OcrAdmission::all()) - else { - panic!("supported call declined") - }; - let mut request = Some(request); - let host = NoopOcrHost; - let mut result = None; - loop { - match call.resume(result.take()).await.unwrap() { - OcrCallStep::Host(OcrHostOperation::PreCall(_)) => break, - OcrCallStep::Host(OcrHostOperation::ProjectRequest) => { - result = Some(OcrHostResult::Request(Ok(( - Box::new(request.take().unwrap()), - false, - )))) - } - OcrCallStep::Host(operation) => result = Some(host.invoke(operation).await), - OcrCallStep::Complete(_) => panic!("provider executed before pre-call result"), - } - } - let selected = crate::ocr::Error::InvalidRequest("cancelled".into()); - assert!(matches!( - call.interrupt(HostFailure::Cancelled(selected.clone())).await, - Err(crate::ocr::Error::InvalidRequest(message)) if message == "cancelled" +async fn cancellation_at_before_send_prevents_execution_and_further_resumption() { + let host = LocalOcrHost::new(wire_request( + "mistral/model", + "http://127.0.0.1:1", + json!({}), )); - assert!( - call.resume(Some(OcrHostResult::Lifecycle(Ok(())))) - .await - .is_err() - ); -} - -#[cfg(unix)] -#[tokio::test] -async fn cancellation_acknowledges_blocking_preparation_completion() { - use std::future::Future; - use std::io::Write; - use std::task::Poll; - - use crate::call_lifecycle::host::HostFailure; - - let path = std::env::temp_dir().join(format!("litellm-ocr-{}.fifo", rand::random::())); - assert!( - std::process::Command::new("mkfifo") - .arg(&path) - .status() - .unwrap() - .success() - ); - let request = wire_request("mistral/model", "http://127.0.0.1:1", json!({})).with_document( - super::OcrDocumentInput::Path { - path: path.clone(), - mime_type: Some("application/pdf".into()), - }, - ); - let NativeOutcome::Completed(mut call) = - OcrCall::admit(super::test_support::ocr_client(), OcrAdmission::all()) - else { - panic!("supported call declined") - }; - let mut request = Some(request); - let mut result = None; - loop { - match call.resume(result.take()).await.unwrap() { - OcrCallStep::Host(OcrHostOperation::ProjectRequest) => break, - OcrCallStep::Host(operation) => result = Some(NoopOcrHost.invoke(operation).await), - OcrCallStep::Complete(_) => panic!("provider executed before request projection"), - } - } - let mut preparation = Box::pin(call.resume(Some(OcrHostResult::Request(Ok(( - Box::new(request.take().unwrap()), - false, - )))))); - std::future::poll_fn(|cx| { - assert!(preparation.as_mut().poll(cx).is_pending()); - Poll::Ready(()) + let (outcome, ops, mut machine) = drive_until(ocr_client(), &host, |_| { + Err(HostFailure::Cancelled(crate::ocr::Error::InvalidRequest( + "cancelled".into(), + ))) }) .await; - drop(preparation); - - let (entered_tx, entered_rx) = tokio::sync::oneshot::channel(); - let (release_tx, release_rx) = std::sync::mpsc::channel(); - let writer_path = path.clone(); - let writer = tokio::task::spawn_blocking(move || { - let mut fifo = std::fs::File::options() - .write(true) - .open(writer_path) - .unwrap(); - entered_tx.send(()).unwrap(); - release_rx.recv().unwrap(); - fifo.write_all(b"document").unwrap(); - }); - tokio::time::timeout(std::time::Duration::from_secs(2), entered_rx) - .await - .unwrap() - .unwrap(); - - let selected = crate::ocr::Error::InvalidRequest("cancelled".into()); - let mut acknowledgement = Box::pin(call.interrupt(HostFailure::Cancelled(selected.clone()))); - std::future::poll_fn(|cx| { - assert!(acknowledgement.as_mut().poll(cx).is_pending()); - Poll::Ready(()) - }) - .await; - release_tx.send(()).unwrap(); assert!( - matches!(acknowledgement.await, Err(crate::ocr::Error::InvalidRequest(message)) if message == "cancelled") + matches!(outcome, Err(crate::ocr::Error::InvalidRequest(message)) if message == "cancelled") ); - writer.await.unwrap(); - std::fs::remove_file(path).unwrap(); + assert_eq!(ops, ["ProjectRequest", "BeforeSend"]); + assert!(machine.resume(Some(HostResult::Emitted)).await.is_err()); } #[tokio::test] async fn missing_host_result_preserves_pending_operation() { - use crate::call_lifecycle::host::HostPhase; - - let NativeOutcome::Completed(mut call) = - OcrCall::admit(super::test_support::ocr_client(), OcrAdmission::all()) - else { - panic!("supported call declined") - }; + let request = wire_request("mistral/model", "http://127.0.0.1:1", json!({})); + let mut machine = ocr_machine(ocr_client()); assert!(matches!( - call.resume(None).await.unwrap(), - OcrCallStep::Host(OcrHostOperation::Lifecycle(HostPhase::Setup)) + machine.resume(None).await.unwrap(), + MachineStep::Host(HostOp::Route(OcrOp::ProjectRequest)) )); - assert!(call.resume(None).await.is_err()); + assert!(machine.resume(None).await.is_err()); assert!(matches!( - call.resume(Some(OcrHostResult::Lifecycle(Ok(())))) + machine + .resume(Some(HostResult::Route(OcrOpResult::Request { + request: Box::new(request), + caller_token: false, + }))) .await .unwrap(), - OcrCallStep::Host(OcrHostOperation::Lifecycle(HostPhase::Prepare)) + MachineStep::Host(HostOp::BeforeSend { .. }) )); } @@ -1036,76 +732,193 @@ impl litellm_auth::TokenProvider for PendingToken { } #[tokio::test] -async fn cancellation_waits_for_provider_capture_drop_even_when_acknowledgement_is_cancelled() { - use std::future::Future; +async fn interrupt_drops_provider_captures_before_returning() { use std::sync::atomic::{AtomicBool, Ordering}; - use std::task::Poll; - use crate::call_lifecycle::host::HostFailure; - - for interrupt_acknowledgement in [false, true] { - let entered = Arc::new(tokio::sync::Notify::new()); - let dropped = Arc::new(AtomicBool::new(false)); - let request = wire_request("azure_ai/mistral-ocr", "https://example.invalid", json!({})); - let request = super::LiteLLMOcrRequest { - transport: super::OcrTransportConfig { - extra_headers: vec![("authorization".into(), "Bearer test-key".into())], - ..request.transport + let entered = Arc::new(tokio::sync::Notify::new()); + let dropped = Arc::new(AtomicBool::new(false)); + let request = wire_request("azure_ai/mistral-ocr", "https://example.invalid", json!({})); + let request = super::LiteLLMOcrRequest { + transport: super::OcrTransportConfig { + extra_headers: vec![("authorization".into(), "Bearer test-key".into())], + ..request.transport + }, + azure_ad_token_provider: Some(litellm_auth::TokenProviderHandle::new(Arc::new( + PendingToken { + entered: entered.clone(), + dropped: dropped.clone(), }, - azure_ad_token_provider: Some(litellm_auth::TokenProviderHandle::new(Arc::new( - PendingToken { - entered: entered.clone(), - dropped: dropped.clone(), - }, - ))), - ..request - }; - let NativeOutcome::Completed(mut call) = - OcrCall::admit(super::test_support::ocr_client(), OcrAdmission::all()) - else { - panic!("supported call declined") - }; - let mut request = Some(request); - let mut result = None; - tokio::time::timeout(std::time::Duration::from_secs(2), async { - loop { - tokio::select! { - _ = entered.notified() => break, - step = call.resume(result.take()) => { - result = Some(match step.unwrap() { - OcrCallStep::Host(OcrHostOperation::ProjectRequest) => OcrHostResult::Request(Ok((Box::new(request.take().unwrap()), false))), - OcrCallStep::Host(operation) => NoopOcrHost.invoke(operation).await, - OcrCallStep::Complete(_) => panic!("pending provider completed"), - }); - } + ))), + ..request + }; + let host = LocalOcrHost::new(request); + let mut machine = ocr_machine(ocr_client()); + let mut result = None; + tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + tokio::select! { + _ = entered.notified() => break, + step = machine.resume(result.take()) => { + result = Some(match step.unwrap() { + MachineStep::Host(HostOp::Route(op)) => HostResult::Route(host.route(op).await.unwrap()), + MachineStep::Host(HostOp::BeforeSend { wire, .. }) => { + HostResult::BeforeSend(wire) + } + MachineStep::Host(HostOp::Emit(_)) => HostResult::Emitted, + MachineStep::Complete(_) => panic!("pending provider completed"), + }); } } - }).await.unwrap(); - assert!(!dropped.load(Ordering::SeqCst)); - let selected = crate::ocr::Error::InvalidRequest("cancelled".into()); - if interrupt_acknowledgement { - let mut acknowledgement = - Box::pin(call.interrupt(HostFailure::Cancelled(selected.clone()))); - std::future::poll_fn(|cx| { - assert!(acknowledgement.as_mut().poll(cx).is_pending()); - Poll::Ready(()) - }) - .await; - drop(acknowledgement); - assert!(!dropped.load(Ordering::SeqCst)); } - let result = tokio::time::timeout( - std::time::Duration::from_secs(2), - call.interrupt(HostFailure::Cancelled(selected.clone())), - ) - .await - .unwrap(); - assert!( - matches!(result, Err(crate::ocr::Error::InvalidRequest(message)) if message == "cancelled") - ); - assert!( - dropped.load(Ordering::SeqCst), - "cancellation returned while provider captures were still alive" - ); + }) + .await + .unwrap(); + assert!(!dropped.load(Ordering::SeqCst)); + let selected = crate::ocr::Error::InvalidRequest("cancelled".into()); + let acknowledgement = machine.interrupt(HostFailure::Cancelled(selected.clone())); + assert!( + dropped.load(Ordering::SeqCst), + "interrupt returned while provider captures were still alive" + ); + assert!( + matches!(acknowledgement.await, Err(crate::ocr::Error::InvalidRequest(message)) if message == "cancelled") + ); +} + +struct CallerTokenHost { + request: Mutex>, + trace: Mutex>, +} + +impl Host for CallerTokenHost { + async fn route(&self, op: OcrOp) -> Result { + match op { + OcrOp::ProjectRequest => { + self.trace.lock().unwrap().push("project".into()); + Ok(OcrOpResult::Request { + request: Box::new(self.request.lock().unwrap().take().unwrap()), + caller_token: true, + }) + } + OcrOp::AcquireAzureAdToken => { + self.trace.lock().unwrap().push("token".into()); + Ok(OcrOpResult::AzureAdToken( + litellm_auth::ResolvedCredential::Static(litellm_auth::SecretValue::new( + "caller-token", + )), + )) + } + OcrOp::ReadDocument => Err(crate::ocr::Error::InvalidRequest("no reader".into())), + } + } + + async fn before_send( + &self, + wire: WireRequest, + _: &litellm_callbacks::event::RequestContext, + ) -> Result { + let is_authorization = |name: &str| name.eq_ignore_ascii_case("authorization"); + let authorization = wire + .headers + .iter() + .find(|(name, _)| is_authorization(name)) + .map(|(_, value)| value.clone()) + .unwrap_or_default(); + self.trace + .lock() + .unwrap() + .push(format!("before_send:{authorization}")); + let headers = wire + .headers + .into_iter() + .map(|(name, value)| match is_authorization(&name) { + true => (name, "Bearer edited".to_string()), + false => (name, value), + }) + .collect(); + Ok(WireRequest { headers, ..wire }) } } + +#[tokio::test] +async fn the_callers_azure_token_is_acquired_before_before_send_which_can_still_replace_it() { + let (base, seen, server) = mock_server(vec![MockResponse::json(json!({"pages":[]}))]).await; + let mut request = wire_request("azure_ai/model", &base, json!({})); + request.credentials.api_key = None; + let host = CallerTokenHost { + request: Mutex::new(Some(request)), + trace: Mutex::new(Vec::new()), + }; + + litellm_callbacks::run::run(ocr_machine(ocr_client()), &host) + .await + .unwrap(); + server.await.unwrap(); + + assert_eq!( + *host.trace.lock().unwrap(), + ["project", "token", "before_send:Bearer caller-token"] + ); + assert!( + seen.lock().unwrap()[0] + .to_ascii_lowercase() + .contains("authorization: bearer edited\r\n") + ); +} + +#[tokio::test] +async fn interrupting_an_in_flight_provider_request_closes_its_connection() { + use tokio::io::AsyncReadExt; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let received = Arc::new(tokio::sync::Notify::new()); + let server_received = received.clone(); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = Vec::new(); + let mut buffer = [0u8; 4096]; + while !request.windows(4).any(|window| window == b"\r\n\r\n") { + let read = socket.read(&mut buffer).await.unwrap(); + request.extend_from_slice(&buffer[..read]); + } + server_received.notify_one(); + loop { + if socket.read(&mut buffer).await.unwrap() == 0 { + break; + } + } + }); + let host = LocalOcrHost::new(wire_request("mistral/model", &base, json!({}))); + let mut machine = ocr_machine(ocr_client()); + let mut result = None; + tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + tokio::select! { + _ = received.notified() => break, + step = machine.resume(result.take()) => { + result = Some(match step.unwrap() { + MachineStep::Host(HostOp::Route(op)) => HostResult::Route(host.route(op).await.unwrap()), + MachineStep::Host(HostOp::BeforeSend { wire, .. }) => HostResult::BeforeSend(wire), + MachineStep::Host(HostOp::Emit(_)) => HostResult::Emitted, + MachineStep::Complete(_) => panic!("the stalled provider completed"), + }); + } + } + } + }) + .await + .unwrap(); + + let cancelled = crate::ocr::Error::InvalidRequest("cancelled".into()); + assert!( + machine + .interrupt(HostFailure::Cancelled(cancelled)) + .await + .is_err() + ); + tokio::time::timeout(std::time::Duration::from_secs(1), server) + .await + .expect("the provider connection stayed open after the interrupt") + .unwrap(); +} diff --git a/litellm-rust/crates/core/tests/ocr/passthrough.rs b/litellm-rust/crates/core/tests/ocr/passthrough.rs new file mode 100644 index 00000000000..c1cd1adf291 --- /dev/null +++ b/litellm-rust/crates/core/tests/ocr/passthrough.rs @@ -0,0 +1,279 @@ +use std::collections::BTreeSet; +use std::sync::{Arc, Mutex}; + +use litellm_callbacks::event::{RequestContext, WireRequest}; +use rstest::rstest; +use rstest_reuse::{self, apply, template}; +use serde_json::{Map, Value, json}; + +use super::LocalOcrHost; +use super::test_support::{ + MockResponse, SERVED_DOCUMENT, document_server, mock_server, perform_ocr_with, request_body, + wire_request_with_document, +}; + +#[derive(Clone, Copy, Debug)] +enum Route { + Mistral, + AzureAi, + VertexMistral, + AzureCohereParse, + Cohere, +} + +impl Route { + fn model(self) -> &'static str { + match self { + Self::Mistral => "mistral/model", + Self::AzureAi => "azure_ai/model", + Self::VertexMistral => "vertex_ai/mistral-ocr-maas", + Self::AzureCohereParse => "azure_ai/cohere-parse", + Self::Cohere => "cohere/model", + } + } + + fn document_type(self) -> &'static str { + match self { + Self::Mistral | Self::AzureAi | Self::VertexMistral => "document_url", + Self::AzureCohereParse | Self::Cohere => "image_url", + } + } + + fn options(self) -> Value { + match self { + Self::Mistral | Self::AzureAi => json!({"pages": [0]}), + Self::VertexMistral => json!({"pages": [0], "vertex_project": "project-1"}), + Self::AzureCohereParse | Self::Cohere => json!({"output_format": "markdown"}), + } + } +} + +#[derive(Clone, Copy, Debug)] +enum Source { + Inline, + Remote, + RemoteWithExtraField, +} + +/// What the host does to the wire request in `before_send`. +#[derive(Clone, Copy, Debug)] +enum Host { + Detached, + /// What `litellm-callbacks-legacy` does before `pre_call`: every passthrough body key + /// is replaced by the caller's own value. + Realiasing, + ReplacesDocument, +} + +const REPLACED_DOCUMENT: &str = "data:image/png;base64,cmVwbGFjZWQ="; + +impl Host { + fn before_send( + self, + caller: &Map, + wire: WireRequest, + context: &RequestContext, + ) -> WireRequest { + let Value::Object(fields) = wire.body else { + return wire; + }; + let body = fields + .into_iter() + .map(|(name, value)| match self { + Self::Detached => (name, value), + Self::Realiasing => { + let aliased = context + .passthrough_fields + .contains(&name) + .then(|| caller.get(&name).cloned()) + .flatten() + .unwrap_or(value); + (name, aliased) + } + Self::ReplacesDocument if name == "document" => { + let document_type = value["type"].clone(); + let key = document_type.as_str().unwrap_or_default().to_string(); + (name, json!({"type": document_type, key: REPLACED_DOCUMENT})) + } + Self::ReplacesDocument => (name, value), + }) + .collect(); + WireRequest { + body: Value::Object(body), + ..wire + } + } +} + +struct Sent { + caller: Map, + result: Result<(), crate::ocr::Error>, + before_send: Option<(WireRequest, RequestContext)>, + provider_body: Option, +} + +fn caller_document(route: Route, source: Source, document_base: &str) -> Value { + let document_type = route.document_type(); + let remote = format!("{document_base}/scan.png"); + match source { + Source::Inline => { + json!({"type": document_type, document_type: "data:image/png;base64,YWJj"}) + } + Source::Remote => json!({"type": document_type, document_type: remote}), + Source::RemoteWithExtraField => { + json!({"type": document_type, document_type: remote, "document_name": "scan.png"}) + } + } +} + +async fn send(route: Route, source: Source, host: Host, document_base: &str) -> Sent { + let (base, seen, provider) = mock_server(vec![MockResponse::json(json!({"pages": []}))]).await; + let document = caller_document(route, source, document_base); + let caller: Map = route + .options() + .as_object() + .unwrap() + .clone() + .into_iter() + .chain([("document".to_string(), document.clone())]) + .collect(); + let observed = Arc::new(Mutex::new(None)); + let captured = observed.clone(); + let host_caller = caller.clone(); + let request = wire_request_with_document(route.model(), &base, document, route.options()); + let local = LocalOcrHost::new(request).with_before_send(move |wire, context| { + *captured.lock().unwrap() = Some((wire.clone(), context.clone())); + Ok(host.before_send(&host_caller, wire, context)) + }); + let result = perform_ocr_with(local).await.map(|_| ()); + match result { + Ok(()) => provider.await.unwrap(), + Err(_) => provider.abort(), + } + let provider_body = seen + .lock() + .unwrap() + .first() + .map(|request| request_body(request)); + let before_send = observed.lock().unwrap().take(); + Sent { + caller, + result, + before_send, + provider_body, + } +} + +fn served_document_uri() -> String { + use base64::Engine; + format!( + "data:image/png;base64,{}", + base64::engine::general_purpose::STANDARD.encode(SERVED_DOCUMENT) + ) +} + +#[template] +#[rstest] +fn every_route_and_source( + #[values( + Route::Mistral, + Route::AzureAi, + Route::VertexMistral, + Route::AzureCohereParse, + Route::Cohere + )] + route: Route, + #[values(Source::Inline, Source::Remote, Source::RemoteWithExtraField)] source: Source, +) { +} + +#[template] +#[rstest] +fn every_route( + #[values( + Route::Mistral, + Route::AzureAi, + Route::VertexMistral, + Route::AzureCohereParse, + Route::Cohere + )] + route: Route, +) { +} + +#[template] +#[rstest] +#[case::azure_ai(Route::AzureAi)] +#[case::vertex_mistral(Route::VertexMistral)] +#[case::azure_cohere_parse(Route::AzureCohereParse)] +fn inlining_routes(#[case] route: Route) {} + +#[apply(every_route_and_source)] +#[tokio::test] +async fn passthrough_fields_are_exactly_the_caller_values_sent_unchanged( + route: Route, + source: Source, +) { + let (document_base, _documents) = document_server().await; + let sent = send(route, source, Host::Detached, &document_base).await; + sent.result.unwrap(); + let (wire, context) = sent.before_send.unwrap(); + let passthrough: BTreeSet<&str> = context.passthrough_fields.iter().collect(); + let unchanged: BTreeSet<&str> = sent + .caller + .iter() + .filter(|(name, value)| wire.body.get(name.as_str()) == Some(*value)) + .map(|(name, _)| name.as_str()) + .collect(); + assert_eq!( + passthrough, + unchanged, + "body: {:#}\ncaller: {:#}", + wire.body, + Value::Object(sent.caller.clone()) + ); +} + +#[apply(every_route_and_source)] +#[tokio::test] +async fn realiasing_leaves_the_provider_request_unchanged(route: Route, source: Source) { + let (document_base, _documents) = document_server().await; + let detached = send(route, source, Host::Detached, &document_base).await; + let realiased = send(route, source, Host::Realiasing, &document_base).await; + detached.result.unwrap(); + realiased.result.unwrap(); + assert_eq!(realiased.provider_body, detached.provider_body); +} + +#[apply(inlining_routes)] +#[tokio::test] +async fn inlining_routes_send_the_downloaded_document( + route: Route, + #[values(Host::Detached, Host::Realiasing)] host: Host, +) { + let (document_base, _documents) = document_server().await; + let sent = send(route, Source::Remote, host, &document_base).await; + sent.result.unwrap(); + assert_eq!( + sent.provider_body.unwrap()["document"][route.document_type()], + json!(served_document_uri()) + ); +} + +#[apply(every_route)] +#[tokio::test] +async fn document_replaced_by_the_host_reaches_the_provider(route: Route) { + let (document_base, _documents) = document_server().await; + let sent = send( + route, + Source::Remote, + Host::ReplacesDocument, + &document_base, + ) + .await; + sent.result.unwrap(); + assert_eq!( + sent.provider_body.unwrap()["document"][route.document_type()], + json!(REPLACED_DOCUMENT) + ); +} diff --git a/litellm-rust/crates/core/tests/ocr/support.rs b/litellm-rust/crates/core/tests/ocr/support.rs index 44fd0462bbf..224a9d9e8f9 100644 --- a/litellm-rust/crates/core/tests/ocr/support.rs +++ b/litellm-rust/crates/core/tests/ocr/support.rs @@ -1,11 +1,15 @@ use std::sync::{Arc, Mutex}; use serde_json::{Value, json}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpListener; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, +}; -use crate::ocr::wire::{OcrWireRequest, decode_request}; -use crate::ocr::{LiteLLMOcrRequest, LiteLLMOcrResponse, OcrClient}; +use crate::ocr::{ + LiteLLMOcrRequest, LiteLLMOcrResponse, LocalOcrHost, OcrClient, ocr_machine, + wire::{OcrWireRequest, decode_request}, +}; pub(crate) fn ocr_client() -> OcrClient { let document_http = reqwest::Client::builder() @@ -21,10 +25,30 @@ pub(crate) async fn perform_ocr( ocr_client().perform(request).await } +pub(crate) async fn perform_ocr_with( + host: LocalOcrHost, +) -> Result { + litellm_callbacks::run::run(ocr_machine(ocr_client()), &host).await +} + pub(crate) fn wire_request(model: &str, base: &str, options: Value) -> LiteLLMOcrRequest { + wire_request_with_document( + model, + base, + json!({"type":"document_url","document_url":"data:application/pdf;base64,YWJj"}), + options, + ) +} + +pub(crate) fn wire_request_with_document( + model: &str, + base: &str, + document: Value, + options: Value, +) -> LiteLLMOcrRequest { decode_request(OcrWireRequest { model: model.into(), - document: json!({"type":"document_url","document_url":"data:application/pdf;base64,YWJj"}), + document, api_key: Some("test-key".into()), api_base: Some(base.into()), custom_llm_provider: None, @@ -50,6 +74,32 @@ pub(crate) fn with_source(request: LiteLLMOcrRequest, source: &str) -> LiteLLMOc request.with_document(document.into()) } +pub(crate) fn request_body(request: &str) -> Value { + serde_json::from_str(request.split_once("\r\n\r\n").unwrap().1).unwrap() +} + +pub(crate) const SERVED_DOCUMENT: &[u8] = b"\x89PNG served document"; + +/// Serves [`SERVED_DOCUMENT`] as `image/png` to every connection until aborted. +pub(crate) async fn document_server() -> (String, tokio::task::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let task = tokio::spawn(async move { + loop { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut buffer = [0u8; 4096]; + let _ = socket.read(&mut buffer).await.unwrap(); + let head = format!( + "HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + SERVED_DOCUMENT.len() + ); + socket.write_all(head.as_bytes()).await.unwrap(); + socket.write_all(SERVED_DOCUMENT).await.unwrap(); + } + }); + (base, task) +} + pub(crate) struct MockResponse { pub status: u16, pub headers: Vec<(&'static str, String)>, @@ -123,3 +173,13 @@ pub(crate) async fn mock_server( }); (base, requests, task) } + +pub(crate) fn header<'a>(request: &'a str, name: &str) -> Option<&'a str> { + request + .lines() + .take_while(|line| !line.is_empty()) + .find_map(|line| { + let (key, value) = line.split_once(':')?; + key.eq_ignore_ascii_case(name).then(|| value.trim()) + }) +} diff --git a/litellm-rust/crates/core/tests/reducto_ocr.rs b/litellm-rust/crates/core/tests/reducto_ocr.rs index 0c25fd7a051..a4c2119664f 100644 --- a/litellm-rust/crates/core/tests/reducto_ocr.rs +++ b/litellm-rust/crates/core/tests/reducto_ocr.rs @@ -1,10 +1,11 @@ -use std::sync::Arc; - +use litellm_callbacks::event::{CallEvent, WireRequest}; use rstest::rstest; use serde_json::{Value, json}; -use super::hooks::{OcrDuringCallRequest, OcrHookFuture, OcrHooks, OcrPostCallRequest}; -use super::test_support::{MockResponse, mock_server, perform_ocr, wire_request}; +use super::{ + LocalOcrHost, + test_support::{MockResponse, mock_server, perform_ocr, perform_ocr_with, wire_request}, +}; fn request_body(request: &str) -> Value { serde_json::from_str(request.split_once("\r\n\r\n").unwrap().1).unwrap() @@ -129,38 +130,24 @@ async fn data_uri_upload_preserves_multipart_headers( } } -struct ParseBoundary { - request_count: Arc>>, -} - -impl OcrHooks for ParseBoundary { - fn post_call(&self, request: OcrPostCallRequest) -> OcrHookFuture<'_, OcrPostCallRequest> { - Box::pin(async move { - assert_eq!(self.request_count.lock().unwrap().len(), 2); - assert_eq!( - request.original_response, - json!(r#"{"result":{"chunks":[]}}"#) - ); - Ok(request) - }) - } -} - #[tokio::test] -async fn post_call_stays_after_reducto_upload_and_parse() { +async fn response_received_stays_after_reducto_upload_and_parse() { let (base, seen, server) = mock_server(vec![ MockResponse::json(json!({"file_id":"reducto://uploaded.pdf"})), MockResponse::json(json!({"result":{"chunks":[]}})), ]) .await; - let request = super::LiteLLMOcrRequest { - hooks: Arc::new(ParseBoundary { - request_count: seen.clone(), - }), - ..wire_request("reducto/parse-v3", &base, json!({})) - }; + let request_count = seen.clone(); + let host = LocalOcrHost::new(wire_request("reducto/parse-v3", &base, json!({}))).with_observer( + move |event| { + if let CallEvent::ResponseReceived { raw } = event { + assert_eq!(request_count.lock().unwrap().len(), 2); + assert_eq!(raw.body, r#"{"result":{"chunks":[]}}"#); + } + }, + ); - perform_ocr(request).await.unwrap(); + perform_ocr_with(host).await.unwrap(); server.await.unwrap(); assert_eq!(seen.lock().unwrap().len(), 2); } @@ -300,38 +287,66 @@ async fn facade_omits_native_response_by_default_and_preserves_auth_priority() { ); } -struct RewriteDocument; +#[tokio::test] +async fn native_format_retains_the_provider_response() { + let raw = json!({ + "result":{"chunks":[{"content":"native OCR response"}]}, + "usage":{"num_pages":1} + }); + let (base, _, server) = mock_server(vec![MockResponse::json(raw.clone())]).await; + let request = super::test_support::with_source( + wire_request("reducto/parse-v3", &base, json!({"req_format":"native"})), + "reducto://ready.pdf", + ); -impl OcrHooks for RewriteDocument { - fn intercepts_requests(&self) -> bool { - true - } + let response = perform_ocr(request).await.unwrap(); + server.await.unwrap(); - fn during_call( - &self, - request: OcrDuringCallRequest, - ) -> OcrHookFuture<'_, OcrDuringCallRequest> { - Box::pin(async move { - assert_eq!( - request.body["document_url"], - "data:application/pdf;base64,YWJj" - ); - Ok(OcrDuringCallRequest { - body: json!({"type":"document_url","document_url":"reducto://guarded.pdf"}), - ..request - }) - }) - } + assert_eq!(response.pages[0].markdown, "native OCR response"); + assert_eq!(response.provider_native_response.as_ref(), raw.as_object()); +} + +#[tokio::test] +async fn unknown_model_reaches_parse_and_keeps_its_name() { + let (base, seen, server) = mock_server(vec![MockResponse::json(json!({ + "result":{"chunks":[{"content":"future model response"}]} + }))]) + .await; + let request = super::test_support::with_source( + wire_request("reducto/future-parse-model", &base, json!({})), + "reducto://ready.pdf", + ); + + let response = perform_ocr(request).await.unwrap(); + server.await.unwrap(); + + assert_eq!(response.model, "future-parse-model"); + assert_eq!(response.pages[0].markdown, "future model response"); + let requests = seen.lock().unwrap(); + assert!(requests[0].starts_with("POST /parse ")); + assert_eq!( + request_body(&requests[0]), + json!({"input":"reducto://ready.pdf"}) + ); } #[tokio::test] async fn guardrail_rewrites_document_before_upload() { let (base, seen, server) = mock_server(vec![MockResponse::json(json!({"result":{"chunks":[]}}))]).await; - let mut request = wire_request("reducto/parse-v3", &base, json!({})); - request.hooks = Arc::new(RewriteDocument); + let host = LocalOcrHost::new(wire_request("reducto/parse-v3", &base, json!({}))) + .with_before_send(|wire, _| { + assert_eq!( + wire.body["document_url"], + "data:application/pdf;base64,YWJj" + ); + Ok(WireRequest { + body: json!({"type":"document_url","document_url":"reducto://guarded.pdf"}), + ..wire + }) + }); - perform_ocr(request).await.unwrap(); + perform_ocr_with(host).await.unwrap(); server.await.unwrap(); let requests = seen.lock().unwrap(); assert_eq!(requests.len(), 1); diff --git a/litellm-rust/crates/core/tests/vertex_ai_ocr.rs b/litellm-rust/crates/core/tests/vertex_ai_ocr.rs index 858fee1ba3e..9cd735c26dd 100644 --- a/litellm-rust/crates/core/tests/vertex_ai_ocr.rs +++ b/litellm-rust/crates/core/tests/vertex_ai_ocr.rs @@ -102,10 +102,14 @@ async fn request_controlled_api_base_is_rejected_before_vertex_auth() { async fn adapters_build_complete_requests_and_share_mistral_normalization() { use std::time::Duration; - use crate::llms::base_llm::ocr::transformation::BaseOcrConfig; - use crate::llms::mistral::ocr::transformation::MistralOcrConfig; - use crate::llms::vertex_ai::ocr::transformation::VertexAiOcrConfig; - use crate::ocr::test_support::ocr_client; + use crate::{ + llms::{ + base_llm::ocr::transformation::BaseOcrConfig, + mistral::ocr::transformation::MistralOcrConfig, + vertex_ai::ocr::transformation::VertexAiOcrConfig, + }, + ocr::test_support::ocr_client, + }; let client = ocr_client(); let options = json!({ @@ -121,10 +125,12 @@ async fn adapters_build_complete_requests_and_share_mistral_normalization() { options.clone(), ); let vertex = wire_request("vertex_ai/mistral-ocr-maas", "https://vertex.test", options); - let direct = - crate::ocr::prepare::prepare_request(super::test_support::resolved_request(direct)); - let vertex = - crate::ocr::prepare::prepare_request(super::test_support::resolved_request(vertex)); + let direct = crate::ocr::prepare::prepare_request_for_test( + super::test_support::resolved_request(direct), + ); + let vertex = crate::ocr::prepare::prepare_request_for_test( + super::test_support::resolved_request(vertex), + ); let direct_http = MistralOcrConfig .prepare_request(&direct, &client) .await diff --git a/litellm-rust/crates/python-interop/AGENTS.md b/litellm-rust/crates/host-python/AGENTS.md similarity index 53% rename from litellm-rust/crates/python-interop/AGENTS.md rename to litellm-rust/crates/host-python/AGENTS.md index 63996d3a92b..a3fdd2340b3 100644 --- a/litellm-rust/crates/python-interop/AGENTS.md +++ b/litellm-rust/crates/host-python/AGENTS.md @@ -1,7 +1,9 @@ - Target invariants; implementation and runtime validation may lag these rules -- Keep this crate a small, domain-neutral foundation: Python/Serde conversion and interpreter-boundary utilities - - No LiteLLM domain dependencies, route types, callback policy, public API registration or cdylib build features - - Generic code alone does not justify extraction: runtime integration stays in `python-bridge/src/execution.rs`, host adaptation in its `lifecycle.rs` +- Keep this crate the CPython runtime adapter and nothing more: Serde marshalling, interpreter detachment, tokio/asyncio glue, the `Execution` handle, the call driver and the `CallbackAdapter`/`RouteHost` traits + - No LiteLLM domain dependencies beyond `litellm-callbacks`: no route types, no `Logging` policy, no public API registration, no cdylib build features + - The driver emits `Succeeded` or `Failed` exactly once and never dispatches after a cancellation; which Python objects consume those events is the adapter's business + - `RouteHost::invoke` receives the keyword view the adapter's `begin` returned, not the caller's dict; a route host that projects from it inherits that adapter's rewrites (for the legacy adapter: setup, deployment hooks, credential inheritance) + - A failure that surfaces inside the call, including a host op the call asked for, is mapped through the route's `map_failure`; a failure in `begin` or `after_success` is raised as is - Use standard PyO3 ownership and conversion APIs - Prefer `Bound<'py, T>` for attached operations/results, `Py` for retention; binding/unbinding does not copy payloads - Use `pythonize` for selected Serde data, never a JSON-text round trip; share conversion with `Pythonized` @@ -10,7 +12,8 @@ - Use `Python::detach` for Rust-only work; Python operations require attachment - Keep diagnostic counters in the consumer; wrapper invocations do not measure every interpreter release - Release exclusive class borrows/locks before Python calls or decrements that can invoke finalizers; expose retained Python edges to GC without calling Python during traversal -- Keep coroutine driving in the shared Python driver and native adapter - - Driver: `litellm/rust_bridge/lifecycle.py`; handle: `python-bridge/src/lifecycle.rs`; native-backed behavior tests: `python-bridge/tests/lifecycle.py` +- Keep coroutine driving in the shared Python driver and the native handle + - Driver: `litellm/rust_bridge/lifecycle.py`; handle: `src/handle.rs`; call driver: `src/driver.rs`; native-backed behavior tests: `tests/lifecycle.py` + - Every adapter suspension is awaited inline in the caller's task; `into_future` creates a separate task and cannot satisfy this contract - References: [ownership](https://pyo3.rs/v0.29.2/types.html), [conversions](https://pyo3.rs/v0.29.2/conversions/traits.html), [pythonize errors](https://docs.rs/pythonize/0.29.0/src/pythonize/error.rs.html) - [GC](https://pyo3.rs/v0.29.2/class/protocols.html#garbage-collector-integration), [re-entry](https://pyo3.rs/v0.29.2/class/call.html), [parallelism](https://pyo3.rs/v0.29.2/parallelism.html), [async delivery source](https://docs.rs/pyo3-async-runtimes/0.29.0/src/pyo3_async_runtimes/generic.rs.html) diff --git a/litellm-rust/crates/host-python/Cargo.toml b/litellm-rust/crates/host-python/Cargo.toml new file mode 100644 index 00000000000..ae0cebada59 --- /dev/null +++ b/litellm-rust/crates/host-python/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "litellm-host-python" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +futures-util.workspace = true +litellm-callbacks.workspace = true +pyo3.workspace = true +pyo3-async-runtimes.workspace = true +pythonize.workspace = true +serde.workspace = true +tokio = { workspace = true, features = ["sync"] } + +[dev-dependencies] +rstest.workspace = true +serde_json.workspace = true diff --git a/litellm-rust/crates/host-python/src/adapter.rs b/litellm-rust/crates/host-python/src/adapter.rs new file mode 100644 index 00000000000..f1bc3142a25 --- /dev/null +++ b/litellm-rust/crates/host-python/src/adapter.rs @@ -0,0 +1,104 @@ +use litellm_callbacks::event::{CallEvent, RequestContext, Timing, WireRequest}; +use litellm_callbacks::route::Route; +use pyo3::exceptions::PyRuntimeError; +use pyo3::gc::{PyTraverseError, PyVisit}; +use pyo3::prelude::*; +use pyo3::types::PyDict; + +pub fn missing_state() -> PyErr { + PyRuntimeError::new_err("missing native call state") +} + +/// What an adapter step produced: either the value the driver asked for, or a Python +/// awaitable the driver hands back to the caller's task before asking again. +pub enum AdapterStep { + Await(Py), + Arguments(Py), + Wire(Box), + Response(Py), + Done, +} + +/// The host-typed value the driver attaches to a terminal event. +pub enum PublicValue<'a> { + Response(&'a Py), + Error(&'a PyErr), +} + +/// One consumer of a call's lifecycle on the Python side. The driver calls the steps in +/// order: `begin` before the machine starts, `before_send` and `emit` while it runs, +/// `after_success` and one terminal `emit` after it completes. Whenever a step returns +/// [`AdapterStep::Await`], the driver awaits it in the caller's task and continues the +/// same step through `resume`. +/// +/// A step that fails with an ordinary exception fails the call with that exception, +/// except on a terminal event, where the adapter is expected to report and swallow its +/// own errors. An exception that is not a `PyException`, such as a cancellation, ends +/// the call without further dispatch. +pub trait CallbackAdapter: Send + Sync { + fn begin( + &mut self, + py: Python<'_>, + arguments: Py, + started_at: f64, + ) -> PyResult; + + fn before_send( + &mut self, + py: Python<'_>, + wire: Box, + context: &RequestContext, + ) -> PyResult; + + fn after_success( + &mut self, + py: Python<'_>, + response: Py, + timing: Timing, + ) -> PyResult; + + fn emit( + &mut self, + py: Python<'_>, + event: &CallEvent, + public: Option>, + ) -> PyResult; + + fn resume(&mut self, py: Python<'_>, result: PyResult>) -> PyResult; + + fn close(&mut self, py: Python<'_>); + + fn traverse(&self, visit: &PyVisit<'_>) -> Result<(), PyTraverseError>; +} + +/// The Python side of one route: answers the route's own operations, builds the public +/// response and maps failures to public exceptions. +pub trait RouteHost: Send + Sync { + type Route: Route; + + /// `arguments` is the keyword view the callback adapter's `begin` produced, not the + /// caller's own dict. A route host that projects from it inherits whatever that + /// adapter rewrote. + fn invoke( + &mut self, + py: Python<'_>, + arguments: &Bound<'_, PyDict>, + op: ::Op, + ) -> PyResult<::OpResult>; + + fn complete( + &mut self, + py: Python<'_>, + response: ::Response, + ) -> PyResult>; + + fn native_error(error: ::Error) -> PyErr; + + fn host_error(error: &PyErr) -> ::Error; + + fn map_failure(&self, py: Python<'_>, error: &PyErr) -> PyResult; + + fn close(&mut self, py: Python<'_>); + + fn traverse(&self, visit: &PyVisit<'_>) -> Result<(), PyTraverseError>; +} diff --git a/litellm-rust/crates/host-python/src/callable.rs b/litellm-rust/crates/host-python/src/callable.rs new file mode 100644 index 00000000000..424db002b0a --- /dev/null +++ b/litellm-rust/crates/host-python/src/callable.rs @@ -0,0 +1,135 @@ +//! Failures raised by a caller-supplied Python callable. + +use pyo3::exceptions::{PyException, PyRuntimeError, PyTypeError}; +use pyo3::prelude::*; +use pyo3::types::PyString; + +/// Reports a caller-supplied callable's failure under `template`, a Python format string +/// with one field for the original exception, while leaving alone the failures a caller +/// can already read: a `TypeError`, so a rejected return value is not reported twice, and +/// anything that is not a `PyException`, a cancellation for example. Everything else +/// becomes a `RuntimeError` carrying the original as both its `__cause__` and its +/// `__context__`, with the message rendered by Python so the exception's own `__format__` +/// is honored. A `__format__` that raises surfaces as that failure instead, with the +/// original attached as its context. +pub fn wrap_failure(py: Python<'_>, template: &str, result: PyResult) -> PyResult { + result.map_err(|error| { + if error.is_instance_of::(py) || !error.is_instance_of::(py) { + return error; + } + match PyString::new(py, template).call_method1("format", (error.value(py),)) { + Ok(message) => { + let wrapped = PyRuntimeError::new_err(message.unbind()); + wrapped.set_context(py, Some(error.clone_ref(py))); + wrapped.set_cause(py, Some(error)); + wrapped + } + Err(format_error) => { + format_error.set_context(py, Some(error)); + format_error + } + } + }) +} + +#[cfg(test)] +mod tests { + use pyo3::types::PyDict; + + use super::*; + + const TEMPLATE: &str = "Failed to reach the caller: {}"; + + fn raised<'py>(locals: &Bound<'py, PyDict>, name: &str) -> Bound<'py, PyAny> { + locals.get_item(name).unwrap().unwrap() + } + + fn failure<'py>(error: &Bound<'py, PyAny>) -> PyResult> { + Err(PyErr::from_value(error.clone())) + } + + #[test] + fn only_ordinary_exceptions_are_reported_under_the_template() { + crate::initialize_python(); + Python::attach(|py| { + let locals = PyDict::new(py); + py.run( + pyo3::ffi::c_str!( + r#" +class CallerError(Exception): + def __format__(self, specification): + return 'unavailable' +ordinary = CallerError('must use __format__') +type_error = TypeError('signature') +abort = KeyboardInterrupt('cancelled') +"# + ), + Some(&locals), + Some(&locals), + ) + .unwrap(); + + let original = raised(&locals, "ordinary"); + let wrapped = wrap_failure(py, TEMPLATE, failure(&original)).unwrap_err(); + assert!(wrapped.is_instance_of::(py)); + assert!(wrapped.cause(py).unwrap().value(py).is(&original)); + assert!( + wrapped + .value(py) + .getattr("__context__") + .unwrap() + .is(&original) + ); + assert_eq!( + wrapped.value(py).str().unwrap().to_str().unwrap(), + "Failed to reach the caller: unavailable" + ); + + for name in ["type_error", "abort"] { + let original = raised(&locals, name); + let error = wrap_failure(py, TEMPLATE, failure(&original)).unwrap_err(); + assert!(error.value(py).is(&original)); + } + }); + } + + #[test] + fn a_raising_format_surfaces_instead_of_the_report_and_keeps_the_original_as_context() { + crate::initialize_python(); + Python::attach(|py| { + let locals = PyDict::new(py); + py.run( + pyo3::ffi::c_str!( + r#" +class Unformattable(Exception): + def __format__(self, specification): + raise ValueError('formatting failed') +original = Unformattable('cannot render') +"# + ), + Some(&locals), + Some(&locals), + ) + .unwrap(); + + let original = raised(&locals, "original"); + let error = wrap_failure(py, TEMPLATE, failure(&original)).unwrap_err(); + assert!(error.is_instance_of::(py)); + assert!( + error + .value(py) + .getattr("__context__") + .unwrap() + .is(&original) + ); + }); + } + + #[test] + fn successful_results_pass_through_untouched() { + crate::initialize_python(); + Python::attach(|py| { + assert_eq!(wrap_failure(py, TEMPLATE, Ok(7)).unwrap(), 7); + }); + } +} diff --git a/litellm-rust/crates/host-python/src/driver.rs b/litellm-rust/crates/host-python/src/driver.rs new file mode 100644 index 00000000000..8bda13b44d0 --- /dev/null +++ b/litellm-rust/crates/host-python/src/driver.rs @@ -0,0 +1,1185 @@ +use std::sync::Arc; +use std::task::Poll; + +use futures_util::future::{AbortHandle, Abortable}; +use litellm_callbacks::event::{CallEvent, FailureOrigin, Timing, epoch_seconds}; +use litellm_callbacks::host::{HostOp, HostResult, HostStep}; +use litellm_callbacks::machine::{HostFailure, Machine, MachineStep}; +use litellm_callbacks::route::Route; +use pyo3::exceptions::{PyBaseException, PyException, PyRuntimeError}; +use pyo3::gc::{PyTraverseError, PyVisit}; +use pyo3::prelude::*; +use pyo3::types::PyDict; +use tokio::sync::Mutex; + +use crate::adapter::{AdapterStep, CallbackAdapter, PublicValue, RouteHost, missing_state}; +use crate::execution::{poll_async_value, run_async_value, run_sync_value}; +use crate::handle::{Execution, ExecutionBody, ExecutionStep}; + +type RouteOf = ::Route; +type ErrorOf = as Route>::Error; +type ResponseOf = as Route>::Response; +type NativeStep = MachineStep, ResponseOf>; +type NativeResult = Result, ErrorOf>; +type NativeResume = Option>, HostFailure>>>; + +type MachineResult = Result< + MachineStep<::Route, ::Complete>, + <::Route as Route>::Error, +>; + +struct MachineState { + machine: M, + result: Option>, +} + +enum Stage { + Begin, + Call, + AfterSuccess, + Succeeded(Py), + Failed(Py), +} + +#[derive(Clone, Copy)] +enum Expect { + Arguments, + Wire, + Emitted, + Response, + Terminal, +} + +enum Pending { + Native, + Adapter(Expect), +} + +enum Next { + Return(ExecutionStep), + Continue(HostStep, Py>), +} + +struct PythonDriver +where + H: RouteHost, + M: Machine> + 'static, +{ + route: H, + adapter: Box, + machine: Option>>>, + arguments: Option>, + started_at: f64, + ended_at: Option, + stage: Stage, + pending: Option, + native_abort: Option, + interrupted: Option>, + asynchronous: bool, +} + +/// Runs one native call for Python: synchronously, or as a coroutine that awaits every +/// host suspension inline in the caller's task. +pub fn run_call( + py: Python<'_>, + machine: M, + route: H, + adapter: Box, + arguments: Py, + asynchronous: bool, +) -> PyResult> +where + H: RouteHost + 'static, + M: Machine> + 'static, +{ + let mut driver = PythonDriver { + route, + adapter, + machine: Some(Arc::new(Mutex::new(MachineState { + machine, + result: None, + }))), + arguments: Some(arguments), + started_at: 0.0, + ended_at: None, + stage: Stage::Begin, + pending: None, + native_abort: None, + interrupted: None, + asynchronous, + }; + if asynchronous { + let execution = Py::new(py, Execution::new(driver))?; + return py + .import("litellm.rust_bridge.lifecycle")? + .getattr("drive")? + .call1((execution,)) + .map(Bound::unbind); + } + match driver.resume(None)? { + ExecutionStep::Return(value) => Ok(value), + ExecutionStep::Await(_) => Err(PyRuntimeError::new_err("sync call suspended")), + } +} + +fn is_cancellation(py: Python<'_>, error: &PyErr) -> bool { + !error.is_instance_of::(py) +} + +impl PythonDriver +where + H: RouteHost, + M: Machine> + 'static, +{ + fn timing(&self) -> Timing { + Timing { + start_time: self.started_at, + end_time: self.ended_at.unwrap_or_else(epoch_seconds), + } + } + + fn drive( + &mut self, + py: Python<'_>, + result: Option>>, + ) -> PyResult { + match (self.pending.take(), result) { + (None, None) => { + self.started_at = epoch_seconds(); + let arguments = self.arguments.take().ok_or_else(missing_state)?; + match self.adapter.begin(py, arguments, self.started_at) { + Ok(step) => self.on_adapter(py, step, Expect::Arguments), + Err(error) => self.adapter_failed(py, error), + } + } + (Some(Pending::Native), Some(Ok(_))) => { + let result = self.take_native_result()?; + self.run_steps(py, HostStep::Ready(result)) + } + (Some(Pending::Native), Some(Err(error))) => self.interrupt(py, error), + (Some(Pending::Adapter(expect)), Some(result)) => { + match self.adapter.resume(py, result) { + Ok(step) => self.on_adapter(py, step, expect), + Err(error) => self.adapter_failed(py, error), + } + } + _ => Err(missing_state()), + } + } + + fn on_adapter( + &mut self, + py: Python<'_>, + step: AdapterStep, + expect: Expect, + ) -> PyResult { + match (expect, step) { + (_, AdapterStep::Await(awaitable)) => { + self.pending = Some(Pending::Adapter(expect)); + Ok(ExecutionStep::Await(awaitable)) + } + (Expect::Arguments, AdapterStep::Arguments(arguments)) => { + self.arguments = Some(arguments); + self.stage = Stage::Call; + self.resume_machine(py, None) + } + (Expect::Wire, AdapterStep::Wire(wire)) => { + self.resume_machine(py, Some(Ok(HostResult::BeforeSend(wire)))) + } + (Expect::Emitted, AdapterStep::Done) => { + self.resume_machine(py, Some(Ok(HostResult::Emitted))) + } + (Expect::Response, AdapterStep::Response(response)) => self.succeeded(py, response), + (Expect::Terminal, AdapterStep::Done) => match &self.stage { + Stage::Succeeded(response) => Ok(ExecutionStep::Return(response.clone_ref(py))), + Stage::Failed(error) => Err(PyErr::from_value(error.bind(py).clone().into_any())), + _ => Err(missing_state()), + }, + _ => Err(missing_state()), + } + } + + fn adapter_failed(&mut self, py: Python<'_>, error: PyErr) -> PyResult { + match self.stage { + Stage::Begin | Stage::AfterSuccess => self.failure(py, error, FailureOrigin::Host), + Stage::Call => self.interrupt(py, error), + Stage::Succeeded(_) | Stage::Failed(_) => Err(error), + } + } + + fn resume_machine( + &mut self, + py: Python<'_>, + result: NativeResume, + ) -> PyResult { + let step = self.resume_core(py, result)?; + self.run_steps(py, step) + } + + fn run_steps( + &mut self, + py: Python<'_>, + mut step: HostStep, Py>, + ) -> PyResult { + loop { + let result = match step { + HostStep::Suspend(awaitable) => { + self.pending = Some(Pending::Native); + return Ok(ExecutionStep::Await(awaitable)); + } + HostStep::Ready(result) => result, + }; + step = match self.handle_native(py, result)? { + Next::Return(step) => return Ok(step), + Next::Continue(step) => step, + }; + } + } + + /// Answers one machine step: performs the op it asked for, or finishes the call. + fn handle_native(&mut self, py: Python<'_>, result: NativeResult) -> PyResult> { + let op = match result { + Ok(MachineStep::Host(op)) => op, + Ok(MachineStep::Complete(response)) => { + return self.completed(py, response).map(Next::Return); + } + Err(error) => return self.machine_failed(py, error).map(Next::Return), + }; + let answer = match op { + HostOp::Route(op) => { + let arguments = self.arguments.as_ref().ok_or_else(missing_state)?; + self.route + .invoke(py, arguments.bind(py), op) + .map(HostResult::Route) + } + HostOp::BeforeSend { wire, context } => { + match self.adapter.before_send(py, wire, &context) { + Ok(AdapterStep::Wire(wire)) => Ok(HostResult::BeforeSend(wire)), + Ok(AdapterStep::Await(awaitable)) => { + self.pending = Some(Pending::Adapter(Expect::Wire)); + return Ok(Next::Return(ExecutionStep::Await(awaitable))); + } + Ok(_) => return Err(missing_state()), + Err(error) => Err(error), + } + } + HostOp::Emit(event) => match self.adapter.emit(py, &event, None) { + Ok(AdapterStep::Done) => Ok(HostResult::Emitted), + Ok(AdapterStep::Await(awaitable)) => { + self.pending = Some(Pending::Adapter(Expect::Emitted)); + return Ok(Next::Return(ExecutionStep::Await(awaitable))); + } + Ok(_) => return Err(missing_state()), + Err(error) => Err(error), + }, + }; + match answer { + Ok(answer) => self.resume_core(py, Some(Ok(answer))).map(Next::Continue), + Err(error) => self.interrupt(py, error).map(Next::Return), + } + } + + fn interrupt(&mut self, py: Python<'_>, error: PyErr) -> PyResult { + let cancelled = is_cancellation(py, &error); + let native = H::host_error(&error); + self.interrupted = Some(error.into_value(py)); + let failure = if cancelled { + HostFailure::Cancelled(native) + } else { + HostFailure::Error(native) + }; + self.resume_machine(py, Some(Err(failure))) + } + + fn resume_core( + &mut self, + py: Python<'_>, + result: NativeResume, + ) -> PyResult, Py>> { + let state = Arc::clone(self.machine.as_ref().ok_or_else(missing_state)?); + let future = async move { + let mut state = state.lock().await; + let result = match result { + Some(Err(failure)) => state + .machine + .interrupt(failure) + .await + .map(MachineStep::Complete), + Some(Ok(result)) => state.machine.resume(Some(result)).await, + None => state.machine.resume(None).await, + }; + state.result = Some(result); + Ok(()) + }; + if self.asynchronous { + let mut future = Box::pin(future); + if let Poll::Ready(()) = poll_async_value(py, future.as_mut())? { + return Ok(HostStep::Ready(self.take_native_result()?)); + } + let (abort, registration) = AbortHandle::new_pair(); + self.native_abort = Some(abort); + Ok(HostStep::Suspend( + run_async_value(py, async move { + Abortable::new(future, registration) + .await + .map_err(|_| PyRuntimeError::new_err("native execution closed"))? + })? + .unbind(), + )) + } else { + run_sync_value(py, future)?; + Ok(HostStep::Ready(self.take_native_result()?)) + } + } + + fn take_native_result(&self) -> PyResult> { + self.machine + .as_ref() + .ok_or_else(missing_state)? + .try_lock() + .map_err(|_| missing_state())? + .result + .take() + .ok_or_else(missing_state) + } + + fn completed(&mut self, py: Python<'_>, response: ResponseOf) -> PyResult { + self.ended_at = Some(epoch_seconds()); + let public = match self.route.complete(py, response) { + Ok(public) => public, + Err(error) => return self.failure(py, error, FailureOrigin::Call), + }; + self.stage = Stage::AfterSuccess; + match self.adapter.after_success(py, public, self.timing()) { + Ok(step) => self.on_adapter(py, step, Expect::Response), + Err(error) => self.failure(py, error, FailureOrigin::Host), + } + } + + fn machine_failed(&mut self, py: Python<'_>, error: ErrorOf) -> PyResult { + self.ended_at.get_or_insert_with(epoch_seconds); + let error = match self.interrupted.take() { + Some(retained) => PyErr::from_value(retained.into_bound(py).into_any()), + None => H::native_error(error), + }; + self.failure(py, error, FailureOrigin::Call) + } + + fn succeeded(&mut self, py: Python<'_>, response: Py) -> PyResult { + let event = CallEvent::Succeeded { + timing: self.timing(), + }; + let step = self + .adapter + .emit(py, &event, Some(PublicValue::Response(&response)))?; + self.stage = Stage::Succeeded(response); + self.on_adapter(py, step, Expect::Terminal) + } + + fn failure( + &mut self, + py: Python<'_>, + error: PyErr, + origin: FailureOrigin, + ) -> PyResult { + self.ended_at.get_or_insert_with(epoch_seconds); + if is_cancellation(py, &error) { + return Err(error); + } + let public = match origin { + FailureOrigin::Call => self.route.map_failure(py, &error).unwrap_or(error), + FailureOrigin::Host => error, + }; + let event = CallEvent::Failed { + timing: self.timing(), + origin, + }; + let step = self + .adapter + .emit(py, &event, Some(PublicValue::Error(&public)))?; + self.stage = Stage::Failed(public.into_value(py)); + self.on_adapter(py, step, Expect::Terminal) + } + + fn clear(&mut self) { + if let Some(abort) = self.native_abort.take() { + abort.abort(); + } + if self.machine.take().is_some() { + Python::attach(|py| { + self.adapter.close(py); + self.route.close(py); + }); + } + } +} + +impl ExecutionBody for PythonDriver +where + H: RouteHost, + M: Machine> + 'static, +{ + fn resume(&mut self, result: Option>>) -> PyResult { + Python::attach(|py| self.drive(py, result)) + } + + fn traverse(&self, visit: &PyVisit<'_>) -> Result<(), PyTraverseError> { + self.route.traverse(visit)?; + self.adapter.traverse(visit)?; + visit.call(&self.arguments)?; + visit.call(&self.interrupted)?; + match &self.stage { + Stage::Succeeded(response) => visit.call(response), + Stage::Failed(error) => visit.call(error), + _ => Ok(()), + } + } +} + +impl Drop for PythonDriver +where + H: RouteHost, + M: Machine> + 'static, +{ + fn drop(&mut self) { + self.clear(); + } +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + use litellm_callbacks::event::{RequestContext, WireRequest}; + use litellm_callbacks::machine::{Interrupted, Step}; + use pyo3::exceptions::{PyBaseException, PyValueError}; + use pyo3::types::PyDict; + + use super::*; + + static PYTHON_GLOBALS: Mutex<()> = Mutex::new(()); + + fn install_lifecycle_module(py: Python<'_>) -> Bound<'_, PyModule> { + py.run( + pyo3::ffi::c_str!( + r#" +import sys +import types + +sys.modules.setdefault('litellm', types.ModuleType('litellm')) +sys.modules.setdefault('litellm.rust_bridge', types.ModuleType('litellm.rust_bridge')) +"# + ), + None, + None, + ) + .unwrap(); + let source = + std::ffi::CString::new(include_str!("../../../../litellm/rust_bridge/lifecycle.py")) + .unwrap(); + PyModule::from_code( + py, + &source, + pyo3::ffi::c_str!("lifecycle.py"), + pyo3::ffi::c_str!("litellm.rust_bridge.lifecycle"), + ) + .unwrap() + } + + #[derive(Clone, Debug, PartialEq, Eq)] + struct Error(String); + + struct Synthetic; + + impl Route for Synthetic { + type Response = String; + type Error = Error; + type Op = &'static str; + type OpResult = String; + } + + /// Yields the scripted ops in order, then completes or fails as scripted. + struct ScriptedMachine { + ops: Vec>, + outcome: Option>, + answers: Vec, + } + + fn wire() -> WireRequest { + WireRequest { + url: "https://example.invalid".into(), + headers: Vec::new(), + body: serde_json::json!({}), + } + } + + fn context() -> RequestContext { + RequestContext { + model: "model".into(), + custom_llm_provider: "provider".into(), + optional_params: serde_json::json!({}), + passthrough_fields: Default::default(), + secret_fields: Vec::new(), + } + } + + impl Machine for ScriptedMachine { + type Route = Synthetic; + type Complete = String; + + fn resume(&mut self, result: Option>) -> Step<'_, Self> { + Box::pin(async move { + if let Some(result) = result { + self.answers.push(match result { + HostResult::Route(value) => value, + HostResult::BeforeSend(wire) => wire.url, + HostResult::Emitted => "emitted".into(), + }); + } + if !self.ops.is_empty() { + return Ok(MachineStep::Host(self.ops.remove(0))); + } + self.outcome + .take() + .ok_or_else(|| Error("resumed after completion".into()))? + .map(MachineStep::Complete) + }) + } + + fn interrupt(&mut self, failure: HostFailure) -> Interrupted<'_, Self> { + self.ops.clear(); + self.outcome = None; + Box::pin(async move { Err(failure.into_error()) }) + } + } + + #[derive(Default)] + struct Log(Arc>>); + + impl Log { + fn push(&self, entry: impl Into) { + self.0.lock().unwrap().push(entry.into()); + } + + fn entries(&self) -> Vec { + self.0.lock().unwrap().clone() + } + } + + struct SyntheticHost { + log: Log, + fail_op: bool, + } + + impl RouteHost for SyntheticHost { + type Route = Synthetic; + + fn invoke( + &mut self, + _: Python<'_>, + arguments: &Bound<'_, PyDict>, + op: &'static str, + ) -> PyResult { + self.log.push(format!("route:{op}")); + if self.fail_op { + return Err(PyValueError::new_err("op failed")); + } + Ok(format!("{op}:{}", arguments.len())) + } + + fn complete(&mut self, py: Python<'_>, response: String) -> PyResult> { + self.log.push("complete"); + Ok(pyo3::types::PyString::new(py, &response) + .into_any() + .unbind()) + } + + fn native_error(error: Error) -> PyErr { + PyValueError::new_err(error.0) + } + + fn host_error(error: &PyErr) -> Error { + Error(error.to_string()) + } + + fn map_failure(&self, py: Python<'_>, error: &PyErr) -> PyResult { + self.log.push("map_failure"); + Ok(PyValueError::new_err(format!( + "mapped: {}", + error.value(py) + ))) + } + + fn close(&mut self, _: Python<'_>) { + self.log.push("route.close"); + } + + fn traverse(&self, _: &PyVisit<'_>) -> Result<(), PyTraverseError> { + Ok(()) + } + } + + #[derive(Clone, Copy)] + enum AdapterScript { + Plain, + FailBegin, + ReplaceResponse, + FailAfterSuccess, + } + + struct SyntheticAdapter { + log: Log, + script: AdapterScript, + } + + impl CallbackAdapter for SyntheticAdapter { + fn begin(&mut self, _: Python<'_>, arguments: Py, _: f64) -> PyResult { + self.log.push("begin"); + if matches!(self.script, AdapterScript::FailBegin) { + return Err(PyValueError::new_err("begin failed")); + } + Ok(AdapterStep::Arguments(arguments)) + } + + fn before_send( + &mut self, + _: Python<'_>, + wire: Box, + _: &RequestContext, + ) -> PyResult { + self.log.push("before_send"); + Ok(AdapterStep::Wire(Box::new(WireRequest { + url: "rewritten".into(), + ..*wire + }))) + } + + fn after_success( + &mut self, + py: Python<'_>, + response: Py, + _: Timing, + ) -> PyResult { + self.log.push("after_success"); + match self.script { + AdapterScript::ReplaceResponse => Ok(AdapterStep::Response( + "replaced".into_pyobject(py)?.into_any().unbind(), + )), + AdapterScript::FailAfterSuccess => { + Err(PyValueError::new_err("after_success failed")) + } + AdapterScript::Plain | AdapterScript::FailBegin => { + Ok(AdapterStep::Response(response)) + } + } + } + + fn emit( + &mut self, + py: Python<'_>, + event: &CallEvent, + public: Option>, + ) -> PyResult { + self.log.push(match (event, public) { + (CallEvent::ResponseReceived { raw }, None) => format!("response:{}", raw.body), + (CallEvent::Succeeded { .. }, Some(PublicValue::Response(value))) => { + format!("succeeded:{}", value.bind(py)) + } + (CallEvent::Failed { origin, .. }, Some(PublicValue::Error(error))) => { + format!("failed:{origin:?}:{}", error.value(py)) + } + _ => "unexpected".into(), + }); + Ok(AdapterStep::Done) + } + + fn resume(&mut self, _: Python<'_>, _: PyResult>) -> PyResult { + Err(missing_state()) + } + + fn close(&mut self, _: Python<'_>) { + self.log.push("adapter.close"); + } + + fn traverse(&self, _: &PyVisit<'_>) -> Result<(), PyTraverseError> { + Ok(()) + } + } + + fn run_scripted( + py: Python<'_>, + machine: ScriptedMachine, + fail_op: bool, + script: AdapterScript, + asynchronous: bool, + ) -> (PyResult>, Vec) { + let log = Log::default(); + let route = SyntheticHost { + log: Log(log.0.clone()), + fail_op, + }; + let adapter = SyntheticAdapter { + log: Log(log.0.clone()), + script, + }; + let arguments = PyDict::new(py); + arguments.set_item("model", "m").unwrap(); + let result = run_call( + py, + machine, + route, + Box::new(adapter), + arguments.unbind(), + asynchronous, + ); + let result = if asynchronous { + result.and_then(|coroutine| { + let completed = coroutine + .call_method1(py, "send", (py.None(),)) + .unwrap_err(); + if !completed.is_instance_of::(py) { + return Err(completed); + } + completed.value(py).getattr("value").map(Bound::unbind) + }) + } else { + result + }; + (result, log.entries()) + } + + fn success_machine() -> ScriptedMachine { + ScriptedMachine { + ops: vec![ + HostOp::Route("project"), + HostOp::BeforeSend { + wire: Box::new(wire()), + context: Box::new(context()), + }, + HostOp::Emit(CallEvent::ResponseReceived { + raw: litellm_callbacks::event::RawResponse { body: "raw".into() }, + }), + ], + outcome: Some(Ok("done".into())), + answers: Vec::new(), + } + } + + #[test] + fn success_runs_every_step_in_order_and_returns_the_public_response() { + let _guard = PYTHON_GLOBALS + .lock() + .unwrap_or_else(|error| error.into_inner()); + crate::initialize_python(); + Python::attach(|py| { + install_lifecycle_module(py); + for asynchronous in [false, true] { + let (result, log) = run_scripted( + py, + success_machine(), + false, + AdapterScript::Plain, + asynchronous, + ); + assert_eq!(result.unwrap().extract::(py).unwrap(), "done"); + assert_eq!( + log, + [ + "begin", + "route:project", + "before_send", + "response:raw", + "complete", + "after_success", + "succeeded:done", + "adapter.close", + "route.close", + ] + ); + } + }); + } + + #[test] + fn machine_failures_are_mapped_and_dispatched_once_as_call_failures() { + let _guard = PYTHON_GLOBALS + .lock() + .unwrap_or_else(|error| error.into_inner()); + crate::initialize_python(); + Python::attach(|py| { + let machine = ScriptedMachine { + ops: vec![HostOp::Route("project")], + outcome: Some(Err(Error("provider exploded".into()))), + answers: Vec::new(), + }; + let (result, log) = run_scripted(py, machine, false, AdapterScript::Plain, false); + let error = result.unwrap_err(); + assert_eq!(error.value(py).to_string(), "mapped: provider exploded"); + assert_eq!( + log, + [ + "begin", + "route:project", + "map_failure", + "failed:Call:mapped: provider exploded", + "adapter.close", + "route.close", + ] + ); + }); + } + + #[test] + fn host_operation_failures_interrupt_the_call_and_keep_the_python_exception() { + let _guard = PYTHON_GLOBALS + .lock() + .unwrap_or_else(|error| error.into_inner()); + crate::initialize_python(); + Python::attach(|py| { + let (result, log) = + run_scripted(py, success_machine(), true, AdapterScript::Plain, false); + let error = result.unwrap_err(); + assert_eq!(error.value(py).to_string(), "mapped: op failed"); + assert!(!log.contains(&"before_send".to_string())); + assert!(log.contains(&"failed:Call:mapped: op failed".to_string())); + }); + } + + #[test] + fn begin_failures_are_host_failures_without_provider_mapping() { + let _guard = PYTHON_GLOBALS + .lock() + .unwrap_or_else(|error| error.into_inner()); + crate::initialize_python(); + Python::attach(|py| { + let (result, log) = run_scripted( + py, + success_machine(), + false, + AdapterScript::FailBegin, + false, + ); + let error = result.unwrap_err(); + assert_eq!(error.value(py).to_string(), "begin failed"); + assert_eq!( + log, + [ + "begin", + "failed:Host:begin failed", + "adapter.close", + "route.close" + ] + ); + }); + } + + #[test] + fn the_adapters_finalized_response_is_what_the_call_returns_and_reports() { + let _guard = PYTHON_GLOBALS + .lock() + .unwrap_or_else(|error| error.into_inner()); + crate::initialize_python(); + Python::attach(|py| { + install_lifecycle_module(py); + for asynchronous in [false, true] { + let (result, log) = run_scripted( + py, + success_machine(), + false, + AdapterScript::ReplaceResponse, + asynchronous, + ); + assert_eq!(result.unwrap().extract::(py).unwrap(), "replaced"); + assert!(log.contains(&"succeeded:replaced".to_string())); + assert!(!log.contains(&"succeeded:done".to_string())); + } + }); + } + + #[test] + fn a_failure_while_finalizing_fails_the_call_instead_of_succeeding() { + let _guard = PYTHON_GLOBALS + .lock() + .unwrap_or_else(|error| error.into_inner()); + crate::initialize_python(); + Python::attach(|py| { + install_lifecycle_module(py); + for asynchronous in [false, true] { + let (result, log) = run_scripted( + py, + success_machine(), + false, + AdapterScript::FailAfterSuccess, + asynchronous, + ); + let error = result.unwrap_err(); + assert_eq!(error.value(py).to_string(), "after_success failed"); + assert_eq!( + &log[log.len() - 4..], + [ + "after_success", + "failed:Host:after_success failed", + "adapter.close", + "route.close" + ] + ); + assert!(!log.iter().any(|entry| entry.starts_with("succeeded"))); + } + }); + } + + #[test] + fn cancellation_ends_the_call_without_terminal_dispatch() { + let _guard = PYTHON_GLOBALS + .lock() + .unwrap_or_else(|error| error.into_inner()); + crate::initialize_python(); + Python::attach(|py| { + struct Cancelling(Log); + impl RouteHost for Cancelling { + type Route = Synthetic; + fn invoke( + &mut self, + py: Python<'_>, + _: &Bound<'_, PyDict>, + _: &'static str, + ) -> PyResult { + self.0.push("route"); + Err(PyErr::from_value( + py.import("asyncio") + .unwrap() + .getattr("CancelledError") + .unwrap() + .call0() + .unwrap(), + )) + } + fn complete(&mut self, _: Python<'_>, _: String) -> PyResult> { + Err(missing_state()) + } + fn native_error(error: Error) -> PyErr { + PyValueError::new_err(error.0) + } + fn host_error(error: &PyErr) -> Error { + Error(error.to_string()) + } + fn map_failure(&self, _: Python<'_>, _: &PyErr) -> PyResult { + self.0.push("map_failure"); + Err(missing_state()) + } + fn close(&mut self, _: Python<'_>) {} + fn traverse(&self, _: &PyVisit<'_>) -> Result<(), PyTraverseError> { + Ok(()) + } + } + let log = Log::default(); + let route = Cancelling(Log(log.0.clone())); + let adapter = SyntheticAdapter { + log: Log(log.0.clone()), + script: AdapterScript::Plain, + }; + let error = run_call( + py, + success_machine(), + route, + Box::new(adapter), + PyDict::new(py).unbind(), + false, + ) + .unwrap_err(); + assert!(!error.is_instance_of::(py)); + assert_eq!(log.entries(), ["begin", "route", "adapter.close"]); + }); + } + + #[test] + fn python_driver_preserves_inline_await_and_native_ownership() { + let _guard = PYTHON_GLOBALS + .lock() + .unwrap_or_else(|error| error.into_inner()); + crate::initialize_python(); + Python::attach(|py| { + py.import("asyncio").unwrap(); + let module = install_lifecycle_module(py); + let locals = PyDict::new(py); + locals + .set_item("drive", module.getattr("drive").unwrap()) + .unwrap(); + locals + .set_item( + "await_execution", + wrap_pyfunction!(await_execution, py).unwrap(), + ) + .unwrap(); + locals + .set_item( + "calling_execution", + wrap_pyfunction!(calling_execution, py).unwrap(), + ) + .unwrap(); + let probe = std::ffi::CString::new(include_str!("../tests/lifecycle.py")).unwrap(); + py.run(&probe, Some(&locals), Some(&locals)).unwrap(); + }); + } + struct RetainingHost { + retained: Option>, + } + + impl ExecutionBody for RetainingHost { + fn resume(&mut self, _: Option>>) -> PyResult { + Python::attach(|py| Ok(ExecutionStep::Return(py.None()))) + } + + fn traverse(&self, visit: &PyVisit<'_>) -> Result<(), PyTraverseError> { + visit.call(&self.retained) + } + } + + #[pyfunction] + fn retaining_coroutine(py: Python<'_>, retained: Py) -> PyResult> { + Py::new( + py, + Execution::new(RetainingHost { + retained: Some(retained), + }), + ) + } + + struct AwaitBody(Option>); + + impl ExecutionBody for AwaitBody { + fn resume(&mut self, result: Option>>) -> PyResult { + match self.0.take() { + Some(awaitable) => Ok(ExecutionStep::Await(awaitable)), + None => result + .expect("selected await completed") + .map(ExecutionStep::Return), + } + } + + fn traverse(&self, visit: &PyVisit<'_>) -> Result<(), PyTraverseError> { + visit.call(&self.0) + } + } + + #[pyfunction] + fn await_execution(awaitable: Py) -> Execution { + Execution::new(AwaitBody(Some(awaitable))) + } + + struct CallingBody(Py); + + impl ExecutionBody for CallingBody { + fn resume(&mut self, _: Option>>) -> PyResult { + Python::attach(|py| self.0.call0(py).map(ExecutionStep::Return)) + } + + fn traverse(&self, visit: &PyVisit<'_>) -> Result<(), PyTraverseError> { + visit.call(&self.0) + } + } + + #[pyfunction] + fn calling_execution(callback: Py) -> Execution { + Execution::new(CallingBody(callback)) + } + + struct ErrorBody(Option>); + + impl ExecutionBody for ErrorBody { + fn resume(&mut self, _: Option>>) -> PyResult { + Python::attach(|py| { + Err(PyErr::from_value( + self.0.take().unwrap().into_bound(py).into_any(), + )) + }) + } + + fn traverse(&self, visit: &PyVisit<'_>) -> Result<(), PyTraverseError> { + visit.call(&self.0) + } + } + + #[pyfunction] + fn error_execution(error: Bound<'_, PyBaseException>) -> Execution { + Execution::new(ErrorBody(Some(error.unbind()))) + } + + #[test] + fn retained_exception_frames_are_collectable() { + crate::initialize_python(); + Python::attach(|py| { + let locals = PyDict::new(py); + locals + .set_item( + "error_execution", + wrap_pyfunction!(error_execution, py).unwrap(), + ) + .unwrap(); + py.run( + pyo3::ffi::c_str!( + r#" +import gc +import weakref + +class Retained: + pass + +def cycle(): + retained = Retained() + try: + raise ValueError('retained traceback') + except ValueError as error: + retained.owner = error_execution(error) + return weakref.ref(retained) + +reference = cycle() +gc.collect() +assert reference() is None +"# + ), + Some(&locals), + Some(&locals), + ) + .unwrap(); + }); + } + + #[test] + fn coroutine_collects_cycles_retained_by_bridge_host() { + crate::initialize_python(); + Python::attach(|py| { + let locals = PyDict::new(py); + locals + .set_item( + "retaining_coroutine", + wrap_pyfunction!(retaining_coroutine, py).unwrap(), + ) + .unwrap(); + py.run( + pyo3::ffi::c_str!( + r#" +import gc +import weakref + +class Retained: + pass + +def cycle(): + retained = Retained() + coroutine = retaining_coroutine(retained) + retained.coroutine = coroutine + return weakref.ref(retained) + +retained_ref = cycle() +gc.collect() +assert retained_ref() is None +"# + ), + Some(&locals), + Some(&locals), + ) + .unwrap(); + }); + } +} diff --git a/litellm-rust/crates/python-bridge/src/execution.rs b/litellm-rust/crates/host-python/src/execution.rs similarity index 79% rename from litellm-rust/crates/python-bridge/src/execution.rs rename to litellm-rust/crates/host-python/src/execution.rs index ffc4c186980..45a1183acf5 100644 --- a/litellm-rust/crates/python-bridge/src/execution.rs +++ b/litellm-rust/crates/host-python/src/execution.rs @@ -4,15 +4,15 @@ use std::pin::Pin; use std::task::{Context, Poll, Waker}; use std::time::Duration; +use crate::{Pythonized, panic_to_pyerr, release_gil}; use futures_util::FutureExt; -use litellm_python_interop::{Pythonized, panic_to_pyerr, release_gil}; use pyo3::exceptions::PyRuntimeError; use pyo3::prelude::*; use serde::Serialize; use tokio::runtime::{Handle, Runtime}; use tokio::time::{self, MissedTickBehavior}; -pub(crate) fn run_sync( +pub fn run_sync( py: Python<'_>, future: F, map_error: fn(E) -> PyErr, @@ -30,7 +30,7 @@ where ) } -pub(crate) fn run_sync_value(py: Python<'_>, future: F) -> PyResult +pub fn run_sync_value(py: Python<'_>, future: F) -> PyResult where T: Send + 'static, F: Future> + Send + 'static, @@ -73,7 +73,7 @@ where Pythonized(result).into_pyobject(py).map(Bound::unbind) } -pub(crate) fn run_async( +pub fn run_async( py: Python<'_>, future: F, map_error: fn(E) -> PyErr, @@ -90,7 +90,7 @@ where }) } -pub(crate) fn run_async_value(py: Python<'_>, future: F) -> PyResult> +pub fn run_async_value(py: Python<'_>, future: F) -> PyResult> where T: for<'py> IntoPyObject<'py> + Send + 'static, F: Future> + Send + 'static, @@ -98,7 +98,7 @@ where pyo3_async_runtimes::tokio::future_into_py(py, async move { catch_future_panic(future).await? }) } -pub(crate) fn poll_async_value(py: Python<'_>, future: Pin<&mut F>) -> PyResult> +pub fn poll_async_value(py: Python<'_>, future: Pin<&mut F>) -> PyResult> where T: Send, F: Future> + Send, @@ -158,14 +158,14 @@ where #[cfg(test)] mod tests { use std::ffi::CString; - use std::future::poll_fn; - use std::sync::atomic::{AtomicUsize, Ordering}; + use std::future::{pending, poll_fn}; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, mpsc}; use std::task::Poll; use std::thread; use std::time::Instant; - use litellm_core::messages::Error; + use pyo3::exceptions::PyLookupError; use pyo3::panic::PanicException; use pyo3::types::{PyDict, PyModule}; use rstest::{fixture, rstest}; @@ -188,10 +188,19 @@ mod tests { #[fixture] #[once] fn initialized_python() -> InitializedPython { - Python::initialize(); + crate::initialize_python(); InitializedPython } + #[derive(Debug)] + struct Error(String); + + impl std::fmt::Display for Error { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.0) + } + } + fn runtime_error(error: Error) -> PyErr { PyRuntimeError::new_err(error.to_string()) } @@ -200,6 +209,52 @@ mod tests { panic!("error mapper panicked") } + static ECHO_FUTURE_DROPPED: AtomicBool = AtomicBool::new(false); + + struct EchoDropGuard; + + impl Drop for EchoDropGuard { + fn drop(&mut self) { + ECHO_FUTURE_DROPPED.store(true, Ordering::SeqCst); + } + } + + fn echo_error(error: Error) -> PyErr { + if error.0 == "panic in mapper" { + panic!("error mapper panicked") + } + PyLookupError::new_err(error.0) + } + + #[pyfunction] + fn async_echo(py: Python<'_>, value: String) -> PyResult> { + ECHO_FUTURE_DROPPED.store(false, Ordering::SeqCst); + let drop_guard = (value == "pending").then_some(EchoDropGuard); + run_async( + py, + async move { + let _drop_guard = drop_guard; + tokio::task::yield_now().await; + match value.as_str() { + "error" => Err(Error("mapped error".into())), + "map_panic" => Err(Error("panic in mapper".into())), + "panic" => panic!("route future panicked"), + "pending" => { + pending::<()>().await; + unreachable!() + } + _ => Ok(value), + } + }, + echo_error, + ) + } + + #[pyfunction] + fn echo_future_dropped() -> bool { + ECHO_FUTURE_DROPPED.load(Ordering::SeqCst) + } + struct PanickingOutput; static ASYNC_PROBE_COMPLETED: AtomicUsize = AtomicUsize::new(0); @@ -439,7 +494,7 @@ mod tests { python.attach(|py| { let error = run_sync::( py, - async { Err(Error::InvalidRequest("invalid".to_string())) }, + async { Err(Error("invalid".to_string())) }, panicking_error_mapper, ) .expect_err("panicked mapper should become a Python exception"); @@ -572,4 +627,77 @@ asyncio.run(exercise()) .expect("result delivery should leave Tokio workers responsive"); }); } + + #[rstest] + fn async_runner_delivers_values_and_errors_and_drops_cancelled_futures( + #[from(initialized_python)] python: &InitializedPython, + ) { + python.attach(|py| { + let module = PyModule::new(py, "runtime").expect("module should be created"); + for function in [ + wrap_pyfunction!(async_echo, &module).expect("function should wrap"), + wrap_pyfunction!(echo_future_dropped, &module).expect("function should wrap"), + ] { + module + .add_function(function) + .expect("function should register"); + } + let locals = PyDict::new(py); + locals + .set_item("runtime", &module) + .expect("module should enter Python locals"); + let code = CString::new( + r#" +import asyncio + +async def exercise(): + assert await runtime.async_echo("value") == "value" + + try: + await runtime.async_echo("error") + except LookupError as error: + assert str(error) == "mapped error" + else: + raise AssertionError("mapped error was not raised") + + try: + await runtime.async_echo("panic") + except BaseException as error: + assert type(error).__name__ == "PanicException" + assert str(error) == "route future panicked" + else: + raise AssertionError("panic was not raised") + + try: + await runtime.async_echo("map_panic") + except BaseException as error: + assert type(error).__name__ == "PanicException" + assert str(error) == "error mapper panicked" + else: + raise AssertionError("mapper panic was not raised") + + task = asyncio.ensure_future(runtime.async_echo("pending")) + await asyncio.sleep(0) + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + else: + raise AssertionError("cancelled route completed") + + for _ in range(100): + if runtime.echo_future_dropped(): + break + await asyncio.sleep(0.001) + assert runtime.echo_future_dropped() + +asyncio.run(exercise()) +"#, + ) + .expect("Python source should not contain null bytes"); + py.run(&code, Some(&locals), Some(&locals)) + .expect("async route contract should hold"); + }); + } } diff --git a/litellm-rust/crates/python-interop/src/gil.rs b/litellm-rust/crates/host-python/src/gil.rs similarity index 100% rename from litellm-rust/crates/python-interop/src/gil.rs rename to litellm-rust/crates/host-python/src/gil.rs diff --git a/litellm-rust/crates/python-bridge/src/lifecycle/handle.rs b/litellm-rust/crates/host-python/src/handle.rs similarity index 95% rename from litellm-rust/crates/python-bridge/src/lifecycle/handle.rs rename to litellm-rust/crates/host-python/src/handle.rs index 17a480a7225..d8cd6c92130 100644 --- a/litellm-rust/crates/python-bridge/src/lifecycle/handle.rs +++ b/litellm-rust/crates/host-python/src/handle.rs @@ -1,16 +1,16 @@ use std::panic::{AssertUnwindSafe, catch_unwind}; -use litellm_python_interop::panic_to_pyerr; +use crate::panic_to_pyerr; use pyo3::exceptions::{PyBaseException, PyRuntimeError}; use pyo3::gc::{PyTraverseError, PyVisit}; use pyo3::prelude::*; -pub(super) enum ExecutionStep { +pub enum ExecutionStep { Return(Py), Await(Py), } -pub(super) trait ExecutionBody: Send + Sync { +pub trait ExecutionBody: Send + Sync { fn resume(&mut self, result: Option>>) -> PyResult; fn traverse(&self, visit: &PyVisit<'_>) -> Result<(), PyTraverseError>; } @@ -23,12 +23,12 @@ enum ExecutionState { } #[pyclass] -pub(super) struct Execution { +pub struct Execution { state: ExecutionState, } impl Execution { - pub(super) fn new(body: impl ExecutionBody + 'static) -> Self { + pub fn new(body: impl ExecutionBody + 'static) -> Self { Self { state: ExecutionState::Created(Box::new(body)), } diff --git a/litellm-rust/crates/host-python/src/lib.rs b/litellm-rust/crates/host-python/src/lib.rs new file mode 100644 index 00000000000..bb0b5b1c3b1 --- /dev/null +++ b/litellm-rust/crates/host-python/src/lib.rs @@ -0,0 +1,33 @@ +//! The CPython runtime adapter: value marshalling, interpreter detachment, the tokio and +//! asyncio glue, and the driver that runs a native [`Machine`](litellm_callbacks::machine::Machine) +//! against a Python route host and a callback adapter. Everything here is Python-specific by +//! construction; another host language gets its own crate of the same shape. + +mod adapter; +mod callable; +mod driver; +mod execution; +mod gil; +mod handle; +mod marshal; + +pub use adapter::{AdapterStep, CallbackAdapter, PublicValue, RouteHost, missing_state}; +pub use callable::wrap_failure; +pub use driver::run_call; +pub use execution::{poll_async_value, run_async, run_async_value, run_sync, run_sync_value}; +pub use gil::{release_count, release_gil}; +pub use handle::{Execution, ExecutionBody, ExecutionStep}; +pub use marshal::{Pythonized, from_py, from_py_argument, panic_to_pyerr, to_py}; + +/// Starts the interpreter and imports the standard modules the tests share, once, so +/// parallel test threads never race a first import of `asyncio`. +#[cfg(test)] +pub(crate) fn initialize_python() { + static IMPORTED: std::sync::Once = std::sync::Once::new(); + pyo3::Python::initialize(); + IMPORTED.call_once(|| { + pyo3::Python::attach(|py| { + py.import("asyncio").expect("asyncio imports"); + }); + }); +} diff --git a/litellm-rust/crates/python-interop/src/marshal.rs b/litellm-rust/crates/host-python/src/marshal.rs similarity index 85% rename from litellm-rust/crates/python-interop/src/marshal.rs rename to litellm-rust/crates/host-python/src/marshal.rs index ed4cce862c0..881ad0e0389 100644 --- a/litellm-rust/crates/python-interop/src/marshal.rs +++ b/litellm-rust/crates/host-python/src/marshal.rs @@ -7,14 +7,16 @@ use pyo3::prelude::*; use serde::Serialize; use serde::de::DeserializeOwned; -pub fn from_py(value: &Bound<'_, PyAny>) -> PyResult +/// Converts a `#[pyo3(from_py_with = ...)]` argument, reporting failures as `ValueError` +/// so a bad argument reads as a bad argument rather than as whatever the conversion hit. +pub fn from_py_argument(value: &Bound<'_, PyAny>) -> PyResult where T: DeserializeOwned, { pythonize::depythonize(value).map_err(|error| PyValueError::new_err(error.to_string())) } -pub fn from_py_preserving_errors(value: &Bound<'_, PyAny>) -> PyResult +pub fn from_py(value: &Bound<'_, PyAny>) -> PyResult where T: DeserializeOwned, { @@ -22,15 +24,6 @@ where } pub fn to_py(py: Python<'_>, value: &T) -> PyResult> -where - T: Serialize + ?Sized, -{ - pythonize::pythonize(py, value) - .map(Bound::unbind) - .map_err(|error| PyValueError::new_err(error.to_string())) -} - -pub fn to_py_preserving_errors(py: Python<'_>, value: &T) -> PyResult> where T: Serialize + ?Sized, { @@ -84,7 +77,7 @@ mod tests { #[test] fn pythonized_converts_on_the_attached_thread() { - Python::initialize(); + crate::initialize_python(); Python::attach(|py| { let value: Vec = Pythonized(vec![1, 2, 3]) .into_pyobject(py) @@ -96,7 +89,7 @@ mod tests { #[test] fn pythonized_maps_serializer_panics_to_a_base_exception() { - Python::initialize(); + crate::initialize_python(); Python::attach(|py| { let error = Pythonized(PanickingSerializer) .into_pyobject(py) @@ -108,7 +101,7 @@ mod tests { #[test] fn depythonize_preserves_python_exception_identity_and_traceback() { - Python::initialize(); + crate::initialize_python(); Python::attach(|py| { let locals = pyo3::types::PyDict::new(py); py.run( @@ -127,14 +120,14 @@ value = Broken() ) .unwrap(); let value = locals.get_item("value").unwrap().unwrap(); - let legacy_error = from_py::(&value).unwrap_err(); - assert!(legacy_error.is_instance_of::(py)); + let argument_error = from_py_argument::(&value).unwrap_err(); + assert!(argument_error.is_instance_of::(py)); assert!( - !legacy_error + !argument_error .value(py) .is(locals.get_item("failure").unwrap().unwrap()) ); - let error = from_py_preserving_errors::(&value).unwrap_err(); + let error = from_py::(&value).unwrap_err(); assert!( error .value(py) diff --git a/litellm-rust/crates/python-interop/tests/interop.rs b/litellm-rust/crates/host-python/tests/interop.rs similarity index 93% rename from litellm-rust/crates/python-interop/tests/interop.rs rename to litellm-rust/crates/host-python/tests/interop.rs index 9c456dcb938..37be538b50f 100644 --- a/litellm-rust/crates/python-interop/tests/interop.rs +++ b/litellm-rust/crates/host-python/tests/interop.rs @@ -2,7 +2,7 @@ use pyo3::Python; use rstest::{fixture, rstest}; use serde_json::{Value, json}; -use litellm_python_interop::{from_py, release_count, release_gil, to_py}; +use litellm_host_python::{from_py, release_count, release_gil, to_py}; struct InitializedPython; diff --git a/litellm-rust/crates/python-bridge/tests/lifecycle.py b/litellm-rust/crates/host-python/tests/lifecycle.py similarity index 100% rename from litellm-rust/crates/python-bridge/tests/lifecycle.py rename to litellm-rust/crates/host-python/tests/lifecycle.py diff --git a/litellm-rust/crates/python-bridge/AGENTS.md b/litellm-rust/crates/python-bridge/AGENTS.md index 9262617156b..9932594e2f5 100644 --- a/litellm-rust/crates/python-bridge/AGENTS.md +++ b/litellm-rust/crates/python-bridge/AGENTS.md @@ -1,38 +1,32 @@ - Target invariants, not completion claims; these supersede older conflicting bridge guidance -- Keep this crate the product-specific PyO3 consumer of `litellm-python-interop` - - Own registration, input projection, retained Python state, callback invocation, public response/error construction and host scheduling - - Keep value-oriented execution, sync waiting, nested-runtime checks, signal polling and panic containment in `execution.rs`; native async work uses `pyo3-async-runtimes`, Serde output uses `Pythonized` - - Core owns typed native state, admission, lifecycle sequencing, provider preparation/I/O, normalization and terminal-outcome/dispatch decisions +- Keep this crate the product-specific PyO3 consumer of `litellm-host-python` + - Own registration, input projection, the route host and the caller callables it answers operations with (file readers, token providers), public response/error construction and the per-call composition of machine, route host and callback contract + - Legacy callback sharing (the caller's args, kwargs and request object, body/header roots, `passthrough_fields` re-aliasing) lives in `litellm-callbacks-legacy` behind `PublicCall` and `run_legacy_call`; the bridge hands the public call over and keeps no copy + - Value-oriented execution, sync waiting, nested-runtime checks, signal polling and panic containment live in `litellm-host-python`; native async work uses `pyo3-async-runtimes`, Serde output uses `Pythonized` + - Core owns typed native state, the route machine, provider preparation/I/O and normalization; the host driver owns terminal events; the legacy adapter in `litellm-callbacks-legacy` owns `Logging` dispatch policy - Python, Rust SDK and gateway use one lifecycle-bearing core route entrypoint; provider helpers stay private, never bridge-accessible transport drivers - Built-in provider/config/secret/auth/document preparation stays in Rust; caller-authored callbacks and focused Python-file reads run only at core-selected points - Target GIL-enabled CPython explicitly with `#[pymodule(gil_used = true)]`; detach Rust-only work - Free-threading requires separate runtime/concurrency validation; omitting the attribute does not opt out on PyO3 0.28+ - Preserve public argument binding and Python object provenance - - Retain complete boundary arguments, opaque unknown values, aliases, omitted/default distinctions and deliberate copies; preserve the established deployment-hook kwargs view - - Retain independently captured body/header roots; in-place mutation and logging-envelope field replacement have different effects - Project only consumed fields at reference read points; no eager whole-graph serialization or equality-based alias reconstruction - Preserve provider-specific upload/submission/poll observation and encoding boundaries; signed/build-captured bytes must not be silently reserialized -- Only core's typed, effect-free admission may return `Declined`; conversion errors and all post-admission failures are terminal - - Admission cannot invoke hooks, acquire credentials, consume files/iterators, prepare requests or perform I/O - - Disabled/unavailable native execution or an admission decline may select legacy once; callback exceptions never authorize fallback or replay -- Use one ordinary inline `async def` driver in `litellm/rust_bridge/lifecycle.py`, with the native handle in `src/lifecycle.rs` +- Conversion errors and every failure after the call starts are terminal + - Disabled/unavailable native execution may select legacy once; callback exceptions never authorize fallback or replay +- Use one ordinary inline `async def` driver in `litellm/rust_bridge/lifecycle.py`, with the native handle and call driver in `litellm-host-python` - Contract: `start`, `resume_value`, `resume_error`, idempotent `close`; explicitly tagged `Await`/`Complete` preserve awaitable final values - - Validate Created/Running/Suspended/Closed protocol states; core alone chooses lifecycle phases and result/error policy + - Validate Created/Running/Suspended/Closed protocol states; the machine yields ops, the driver emits one terminal event, the adapter chooses dispatch policy - Defer effectful setup/context reads/timestamps until start; unstarted-handle destruction releases inputs independently of Python `finally` - Catch only the selected await's errors; start/resume errors propagate, `GeneratorExit` closes without further awaits - Inline hooks preserve caller task/thread/loop and context writes; `into_future` creates a separate task and cannot satisfy this contract - - Delivery follows the binding, not callable type; keep direct, awaited, worker, background and deferred behavior distinct -- Finalize fallible public response/error construction, replacements and metadata under core control before terminal dispatch - - Success/failure handler entry receives the exact selected public response/exception; logging projections/redaction/snapshots retain their own copy contracts - - Ordinary failure-callback errors cannot suppress later eligible sync/async callbacks or replace the mapped provider error; control-flow exceptions have phase-specific policy - - Dispatch errors never replay provider work/accepted dispatch or trigger the opposite outcome; proxy acceptance/rejection releases core-owned deferred success at most once +- Finalize fallible public response/error construction, replacements and metadata before terminal dispatch - Make ownership safe across suspension, re-entry, cancellation and GC - Keep native provider state typed in core; do not shuttle it through opaque Python transport/response classes - Prefer one retained `Py` via `PyErr::into_value(py)`; reconstruct transient `PyErr`s, preserving identity, traceback, cause and context - Traverse every owned Python edge, including duplicate references; traversal cannot call Python - Take state out and mark Running under a short borrow, release borrows/locks before Python invocation, publish terminal state before finalizer-capable drops - Close/GC/deferred release are idempotent and re-entry-safe, including during Rust unwinding; release only owned references, never clear caller containers or mask the selected error - - Cancellation signaling is not termination; retain captures until work actually finishes and use a Rust-selected awaited acknowledgement where required, never synchronous close/GC + - The machine owns its in-flight provider future; `interrupt` drops it synchronously, so provider captures are released before the driver returns and no task outlives the call - Verify behavior through a fresh, provenance-checked installed extension and positive native execution evidence before replacing the custom coroutine - Cover admitted provider workflows, binding/read-point/identity behavior, failure continuation, finalization, no replay, deferred gates, re-entry, GC and cancellation termination - Measure real conversion/copy costs before optimizing; preserve input contracts and capture lifetimes with `PyBackedBytes`, and lookup timing when interning names diff --git a/litellm-rust/crates/python-bridge/CLAUDE.md b/litellm-rust/crates/python-bridge/CLAUDE.md index d25ae5a8130..e55bb192cdd 100644 --- a/litellm-rust/crates/python-bridge/CLAUDE.md +++ b/litellm-rust/crates/python-bridge/CLAUDE.md @@ -7,7 +7,7 @@ Rules for `litellm-rust/crates/python-bridge`. `python-bridge` is the PyO3 boundary between Python LiteLLM and Rust transforms. Keep this crate thin. It exposes LiteLLM Rust APIs, assembles domain requests, maps domain errors to Python exceptions, and delegates generic conversion and -GIL handling to `litellm-python-interop`. +GIL handling to `litellm-host-python`. ## Bridge Shape diff --git a/litellm-rust/crates/python-bridge/Cargo.toml b/litellm-rust/crates/python-bridge/Cargo.toml index 6dde7c71af6..2959fac1084 100644 --- a/litellm-rust/crates/python-bridge/Cargo.toml +++ b/litellm-rust/crates/python-bridge/Cargo.toml @@ -17,19 +17,19 @@ panic-test = [] [dependencies] bytes.workspace = true -futures-util.workspace = true -litellm-core.workspace = true litellm-auth.workspace = true +litellm-callbacks-legacy.workspace = true +litellm-core.workspace = true +litellm-host-python.workspace = true litellm-token-counter.workspace = true -litellm-python-interop.workspace = true pyo3.workspace = true pyo3-async-runtimes.workspace = true -serde.workspace = true serde_json.workspace = true tokio = { workspace = true, features = ["sync"] } [dev-dependencies] criterion.workspace = true +futures-util.workspace = true rstest.workspace = true tokio-tungstenite.workspace = true diff --git a/litellm-rust/crates/python-bridge/benches/serialization.rs b/litellm-rust/crates/python-bridge/benches/serialization.rs index 0b9436d0cb7..7641f35932a 100644 --- a/litellm-rust/crates/python-bridge/benches/serialization.rs +++ b/litellm-rust/crates/python-bridge/benches/serialization.rs @@ -2,7 +2,7 @@ use std::hint::black_box; use std::time::Duration; use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main}; -use litellm_python_interop::{from_py, to_py}; +use litellm_host_python::{from_py, to_py}; use pyo3::prelude::*; use pyo3::types::PyDict; use serde_json::{Value, json}; diff --git a/litellm-rust/crates/python-bridge/src/auth.rs b/litellm-rust/crates/python-bridge/src/auth.rs deleted file mode 100644 index dcc1a60e9f0..00000000000 --- a/litellm-rust/crates/python-bridge/src/auth.rs +++ /dev/null @@ -1,194 +0,0 @@ -use litellm_auth::{ResolvedCredential, SecretValue}; -use pyo3::exceptions::{PyException, PyRuntimeError, PyTypeError}; -use pyo3::gc::{PyTraverseError, PyVisit}; -use pyo3::prelude::*; -use pyo3::types::PyString; - -#[derive(Clone, Copy)] -pub(crate) struct TokenProviderContract { - callable_error: &'static str, - token_type_error: &'static str, - callback_error: &'static str, -} - -pub(crate) const AZURE_AD_TOKEN_PROVIDER: TokenProviderContract = TokenProviderContract { - callable_error: "Azure AD token provider must be callable", - token_type_error: "Azure AD token must be a string, got {}", - callback_error: "Failed to get Azure AD token: {}", -}; - -pub(crate) struct PythonTokenProvider { - callback: Py, - contract: TokenProviderContract, -} - -impl PythonTokenProvider { - pub(crate) fn select( - provider: Bound<'_, PyAny>, - contract: TokenProviderContract, - ) -> Option { - (provider.is_callable() && provider.is_truthy().unwrap_or(false)).then(|| Self { - callback: provider.unbind(), - contract, - }) - } - - pub(crate) fn acquire(&self, py: Python<'_>) -> PyResult { - let provider = self.callback.bind(py); - if !provider.is_callable() { - return Err(PyTypeError::new_err(self.contract.callable_error)); - } - let token = (|| { - let token = provider.call0()?; - if !token.is_instance_of::() { - let message = PyString::new(py, self.contract.token_type_error) - .call_method1("format", (token.get_type(),))?; - return Err(PyTypeError::new_err(message.unbind())); - } - Ok(token) - })() - .map_err(|error| { - if error.is_instance_of::(py) || !error.is_instance_of::(py) { - return error; - } - match PyString::new(py, self.contract.callback_error) - .call_method1("format", (error.value(py),)) - { - Ok(message) => { - let wrapped = PyRuntimeError::new_err(message.unbind()); - wrapped.set_context(py, Some(error.clone_ref(py))); - wrapped.set_cause(py, Some(error)); - wrapped - } - Err(format_error) => { - format_error.set_context(py, Some(error)); - format_error - } - } - })?; - Ok(ResolvedCredential::AccessToken { - token: SecretValue::new(token.extract::()?), - expires_on: None, - }) - } - - pub(crate) fn traverse(&self, visit: &PyVisit<'_>) -> Result<(), PyTraverseError> { - visit.call(&self.callback) - } -} - -#[cfg(test)] -mod tests { - use pyo3::exceptions::PyRuntimeError; - use pyo3::types::PyDict; - - use super::*; - - #[test] - fn token_callback_preserves_exception_identity_and_explicit_chaining() { - Python::initialize(); - Python::attach(|py| { - let locals = PyDict::new(py); - py.run( - pyo3::ffi::c_str!( - r#" -class ProviderError(Exception): - def __format__(self, specification): - return 'unavailable' -ordinary = ProviderError('must use __format__') -type_error = TypeError('signature') -abort = KeyboardInterrupt('cancelled') -def provider(error): - def acquire(): - raise error - return acquire -"# - ), - Some(&locals), - Some(&locals), - ) - .unwrap(); - for name in ["ordinary", "type_error", "abort"] { - let original = locals.get_item(name).unwrap().unwrap(); - let callback = locals - .get_item("provider") - .unwrap() - .unwrap() - .call1((&original,)) - .unwrap(); - let provider = - PythonTokenProvider::select(callback, AZURE_AD_TOKEN_PROVIDER).unwrap(); - let error = provider.acquire(py).unwrap_err(); - if name == "ordinary" { - assert!(error.is_instance_of::(py)); - assert!(error.cause(py).unwrap().value(py).is(&original)); - assert!( - error - .value(py) - .getattr("__context__") - .unwrap() - .is(&original) - ); - assert_eq!( - error.value(py).str().unwrap().to_str().unwrap(), - "Failed to get Azure AD token: unavailable" - ); - } else { - assert!(error.value(py).is(&original)); - } - } - }); - } - - #[test] - fn invalid_token_type_formatting_preserves_python_failure_semantics() { - Python::initialize(); - Python::attach(|py| { - let locals = PyDict::new(py); - py.run( - pyo3::ffi::c_str!( - r#" -failure = ValueError('formatting failed') -class TokenType(type): - def __format__(cls, specification): - raise failure -class Token(metaclass=TokenType): - pass -def provider(): - return Token() -"# - ), - Some(&locals), - Some(&locals), - ) - .unwrap(); - let provider = PythonTokenProvider::select( - locals.get_item("provider").unwrap().unwrap(), - AZURE_AD_TOKEN_PROVIDER, - ) - .unwrap(); - let error = provider.acquire(py).unwrap_err(); - assert!(error.is_instance_of::(py)); - assert!( - error - .cause(py) - .unwrap() - .value(py) - .is(locals.get_item("failure").unwrap().unwrap()) - ); - }); - } - - #[test] - fn token_string_extraction_errors_are_not_wrapped_as_callback_failures() { - Python::initialize(); - Python::attach(|py| { - let callback = py - .eval(pyo3::ffi::c_str!("lambda: '\\ud800'"), None, None) - .unwrap(); - let provider = PythonTokenProvider::select(callback, AZURE_AD_TOKEN_PROVIDER).unwrap(); - let error = provider.acquire(py).unwrap_err(); - assert!(error.is_instance_of::(py)); - }); - } -} diff --git a/litellm-rust/crates/python-bridge/src/constants.rs b/litellm-rust/crates/python-bridge/src/constants.rs deleted file mode 100644 index d5cf5749820..00000000000 --- a/litellm-rust/crates/python-bridge/src/constants.rs +++ /dev/null @@ -1,2 +0,0 @@ -/// Concurrent token-count encodes allowed when the core count is unavailable. -pub(crate) const TOKEN_COUNT_FALLBACK_PARALLELISM: usize = 1; diff --git a/litellm-rust/crates/python-bridge/src/credentials.rs b/litellm-rust/crates/python-bridge/src/credentials.rs new file mode 100644 index 00000000000..5a546f9628e --- /dev/null +++ b/litellm-rust/crates/python-bridge/src/credentials.rs @@ -0,0 +1,301 @@ +//! Credentials the caller supplies as Python callables, projected out of a route's +//! keyword arguments and acquired on the host's own thread when the call asks for one. + +use litellm_auth::{ResolvedCredential, SecretValue}; +use litellm_host_python::wrap_failure; +use pyo3::exceptions::PyTypeError; +use pyo3::gc::{PyTraverseError, PyVisit}; +use pyo3::prelude::*; +use pyo3::types::{PyDict, PyString}; + +const NOT_CALLABLE: &str = "Azure AD token provider must be callable"; +const NOT_A_STRING: &str = "Azure AD token must be a string, got {}"; +const FAILED: &str = "Failed to get Azure AD token: {}"; + +/// The `azure_ad_token_provider` keyword argument, kept alive for the rest of the call. +pub(crate) struct CallerTokenProvider { + provider: Py, +} + +/// Reads `azure_ad_token_provider`, ignoring the falsy and non-callable values litellm's +/// public API has always accepted in its place. +pub(crate) fn azure_ad_token_provider( + kwargs: &Bound<'_, PyDict>, +) -> PyResult> { + Ok(kwargs + .get_item("azure_ad_token_provider")? + .filter(|provider| provider.is_callable() && provider.is_truthy().unwrap_or(false)) + .map(|provider| CallerTokenProvider { + provider: provider.unbind(), + })) +} + +impl CallerTokenProvider { + pub(crate) fn acquire(&self, py: Python<'_>) -> PyResult { + let provider = self.provider.bind(py); + if !provider.is_callable() { + return Err(PyTypeError::new_err(NOT_CALLABLE)); + } + let token = wrap_failure( + py, + FAILED, + (|| { + let token = provider.call0()?; + if !token.is_instance_of::() { + let message = PyString::new(py, NOT_A_STRING) + .call_method1("format", (token.get_type(),))?; + return Err(PyTypeError::new_err(message.unbind())); + } + Ok(token) + })(), + )?; + Ok(ResolvedCredential::AccessToken { + token: SecretValue::new(token.extract::()?), + expires_on: None, + }) + } + + pub(crate) fn traverse(&self, visit: &PyVisit<'_>) -> Result<(), PyTraverseError> { + visit.call(&self.provider) + } +} + +#[cfg(test)] +mod tests { + use pyo3::exceptions::{PyRuntimeError, PyUnicodeEncodeError}; + + use super::*; + + fn kwargs<'py>(py: Python<'py>, source: &std::ffi::CStr) -> Bound<'py, PyDict> { + let locals = PyDict::new(py); + py.run(source, Some(&locals), Some(&locals)).unwrap(); + locals + .get_item("kwargs") + .unwrap() + .unwrap() + .cast_into::() + .unwrap() + } + + fn provider<'py>(py: Python<'py>, source: &std::ffi::CStr) -> CallerTokenProvider { + azure_ad_token_provider(&kwargs(py, source)) + .unwrap() + .expect("a callable provider should project") + } + + #[test] + fn an_acquired_token_becomes_an_access_credential_without_an_expiry() { + Python::initialize(); + Python::attach(|py| { + let provider = provider( + py, + c"kwargs = {'azure_ad_token_provider': lambda: 'ey.token'}", + ); + assert_eq!( + provider.acquire(py).unwrap(), + ResolvedCredential::AccessToken { + token: SecretValue::new("ey.token"), + expires_on: None, + } + ); + }); + } + + #[test] + fn a_failing_provider_is_reported_as_an_azure_token_failure() { + Python::initialize(); + Python::attach(|py| { + let locals = PyDict::new(py); + py.run( + pyo3::ffi::c_str!( + r#" +class ProviderError(Exception): + def __format__(self, specification): + return 'unavailable' +original = ProviderError('must use __format__') +def acquire(): + raise original +kwargs = {'azure_ad_token_provider': acquire} +"# + ), + Some(&locals), + Some(&locals), + ) + .unwrap(); + let provider = azure_ad_token_provider( + &locals + .get_item("kwargs") + .unwrap() + .unwrap() + .cast_into::() + .unwrap(), + ) + .unwrap() + .unwrap(); + let error = provider.acquire(py).unwrap_err(); + assert!(error.is_instance_of::(py)); + assert_eq!( + error.value(py).str().unwrap().to_str().unwrap(), + "Failed to get Azure AD token: unavailable" + ); + assert!( + error + .cause(py) + .unwrap() + .value(py) + .is(locals.get_item("original").unwrap().unwrap()) + ); + }); + } + + #[test] + fn a_non_string_token_is_rejected_by_type_and_never_reported_as_a_provider_failure() { + Python::initialize(); + Python::attach(|py| { + let error = provider(py, c"kwargs = {'azure_ad_token_provider': lambda: 1}") + .acquire(py) + .unwrap_err(); + assert!(error.is_instance_of::(py)); + let message = error.value(py).str().unwrap().to_str().unwrap().to_owned(); + assert!( + message.starts_with("Azure AD token must be a string, got "), + "{message}" + ); + assert!(message.contains("int"), "{message}"); + }); + } + + #[test] + fn a_token_type_that_cannot_be_rendered_reports_that_failure_with_the_original_attached() { + Python::initialize(); + Python::attach(|py| { + let locals = PyDict::new(py); + py.run( + pyo3::ffi::c_str!( + r#" +failure = ValueError('formatting failed') +class TokenType(type): + def __format__(cls, specification): + raise failure +class Token(metaclass=TokenType): + pass +kwargs = {'azure_ad_token_provider': lambda: Token()} +"# + ), + Some(&locals), + Some(&locals), + ) + .unwrap(); + let provider = azure_ad_token_provider( + &locals + .get_item("kwargs") + .unwrap() + .unwrap() + .cast_into::() + .unwrap(), + ) + .unwrap() + .unwrap(); + let error = provider.acquire(py).unwrap_err(); + assert!(error.is_instance_of::(py)); + assert!( + error + .cause(py) + .unwrap() + .value(py) + .is(locals.get_item("failure").unwrap().unwrap()) + ); + }); + } + + #[test] + fn an_undecodable_token_keeps_its_own_failure_instead_of_the_provider_report() { + Python::initialize(); + Python::attach(|py| { + let error = provider( + py, + c"kwargs = {'azure_ad_token_provider': lambda: '\\ud800'}", + ) + .acquire(py) + .unwrap_err(); + assert!(error.is_instance_of::(py)); + }); + } + + #[test] + fn a_provider_that_stops_being_callable_after_projection_is_rejected_by_type() { + Python::initialize(); + Python::attach(|py| { + let locals = PyDict::new(py); + py.run( + pyo3::ffi::c_str!( + r#" +class Provider: + def __call__(self): + return 'ey.token' +kwargs = {'azure_ad_token_provider': Provider()} +"# + ), + Some(&locals), + Some(&locals), + ) + .unwrap(); + let provider = azure_ad_token_provider( + &locals + .get_item("kwargs") + .unwrap() + .unwrap() + .cast_into::() + .unwrap(), + ) + .unwrap() + .expect("a callable provider should project"); + py.run( + pyo3::ffi::c_str!("del Provider.__call__"), + Some(&locals), + Some(&locals), + ) + .unwrap(); + let error = provider.acquire(py).unwrap_err(); + assert!(error.is_instance_of::(py)); + assert_eq!( + error.value(py).str().unwrap().to_str().unwrap(), + "Azure AD token provider must be callable" + ); + }); + } + + #[test] + fn only_callable_and_truthy_providers_project() { + Python::initialize(); + Python::attach(|py| { + for source in [ + c"kwargs = {}", + c"kwargs = {'azure_ad_token_provider': None}", + c"kwargs = {'azure_ad_token_provider': 'not-callable'}", + c" +class Falsy: + def __call__(self): + return 'ey.token' + def __bool__(self): + return False +kwargs = {'azure_ad_token_provider': Falsy()} +", + c" +class Unusable: + def __call__(self): + return 'ey.token' + def __bool__(self): + raise RuntimeError('cannot decide') +kwargs = {'azure_ad_token_provider': Unusable()} +", + ] { + assert!( + azure_ad_token_provider(&kwargs(py, source)) + .unwrap() + .is_none() + ); + } + }); + } +} diff --git a/litellm-rust/crates/python-bridge/src/diagnostics.rs b/litellm-rust/crates/python-bridge/src/diagnostics.rs index cc153a89b8f..42db4510faa 100644 --- a/litellm-rust/crates/python-bridge/src/diagnostics.rs +++ b/litellm-rust/crates/python-bridge/src/diagnostics.rs @@ -1,9 +1,9 @@ -use litellm_python_interop::release_count; +use litellm_host_python::release_count; use pyo3::prelude::*; use pyo3::types::PyDict; #[pyfunction] -fn gil_stats(py: Python<'_>) -> PyResult> { +pub(crate) fn gil_stats(py: Python<'_>) -> PyResult> { let stats = PyDict::new(py); stats.set_item("releases", release_count())?; Ok(stats.into_any().unbind()) @@ -11,13 +11,6 @@ fn gil_stats(py: Python<'_>) -> PyResult> { #[cfg(feature = "panic-test")] #[pyfunction] -fn _panic_for_test() { +pub(crate) fn _panic_for_test() { panic!("intentional PyO3 panic smoke test"); } - -pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { - module.add_function(wrap_pyfunction!(gil_stats, module)?)?; - #[cfg(feature = "panic-test")] - module.add_function(wrap_pyfunction!(_panic_for_test, module)?)?; - Ok(()) -} diff --git a/litellm-rust/crates/python-bridge/src/errors.rs b/litellm-rust/crates/python-bridge/src/errors.rs index 93b68dd952f..3d6f4e2a0dd 100644 --- a/litellm-rust/crates/python-bridge/src/errors.rs +++ b/litellm-rust/crates/python-bridge/src/errors.rs @@ -114,12 +114,6 @@ pub(crate) fn chat_completions_error_to_pyerr(error: chat_completions::Error) -> } } -pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { - let py = module.py(); - module.add("RustBridgeDeclined", py.get_type::())?; - module.add("RustUpstreamError", py.get_type::()) -} - #[cfg(test)] mod tests { use super::*; diff --git a/litellm-rust/crates/python-bridge/src/lib.rs b/litellm-rust/crates/python-bridge/src/lib.rs index 0306990fd4d..ca699e7c483 100644 --- a/litellm-rust/crates/python-bridge/src/lib.rs +++ b/litellm-rust/crates/python-bridge/src/lib.rs @@ -1,105 +1,51 @@ -mod auth; -mod constants; +mod credentials; mod diagnostics; mod errors; -mod execution; -mod lifecycle; mod marshal; mod routes; mod token_counter; -use litellm_core::responses::websocket::ResponsesWebSocketConnection as RustResponsesWebSocketConnection; -use pyo3::prelude::*; -use pyo3::types::PyAny; -use serde_json::Value; - -use crate::errors::responses_error_to_pyerr; -use crate::marshal::{marshal_headers, optional_timeout}; - -#[pyclass] -struct ResponsesWebSocketConnection { - inner: RustResponsesWebSocketConnection, -} - -#[pymethods] -impl ResponsesWebSocketConnection { - #[classmethod] - #[pyo3(signature = (url, headers=None, timeout_seconds=None))] - fn connect<'py>( - _cls: &Bound<'py, pyo3::types::PyType>, - py: Python<'py>, - url: String, - #[pyo3(from_py_with = litellm_python_interop::from_py)] headers: Option, - timeout_seconds: Option, - ) -> PyResult> { - let headers = marshal_headers(headers)?; - let timeout = optional_timeout(timeout_seconds); - pyo3_async_runtimes::tokio::future_into_py(py, async move { - let inner = RustResponsesWebSocketConnection::connect_url(&url, &headers, timeout) - .await - .map_err(responses_error_to_pyerr)?; - Ok(ResponsesWebSocketConnection { inner }) - }) - } - - fn send_text<'py>(&self, py: Python<'py>, text: String) -> PyResult> { - let inner = self.inner.clone(); - pyo3_async_runtimes::tokio::future_into_py(py, async move { - inner - .send_text(text) - .await - .map_err(responses_error_to_pyerr) - }) - } - - fn recv_text<'py>(&self, py: Python<'py>) -> PyResult> { - let inner = self.inner.clone(); - pyo3_async_runtimes::tokio::future_into_py(py, async move { - inner.recv_text().await.map_err(responses_error_to_pyerr) - }) - } - - fn close<'py>(&self, py: Python<'py>) -> PyResult> { - let inner = self.inner.clone(); - pyo3_async_runtimes::tokio::future_into_py(py, async move { - inner.close().await.map_err(responses_error_to_pyerr) - }) - } -} - #[pymodule(gil_used = true)] mod _native { - use pyo3::prelude::*; + #[cfg(feature = "panic-test")] + #[pymodule_export] + use crate::diagnostics::_panic_for_test; + #[pymodule_export] + use crate::diagnostics::gil_stats; + #[pymodule_export] + use crate::errors::{RustBridgeDeclined, RustUpstreamError}; + #[pymodule_export] + use crate::routes::audio_transcription::{atranscription, transcription}; + #[pymodule_export] + use crate::routes::chat_completions::{ + achat_completions, chat_completions, chat_completions_decline, + }; + #[pymodule_export] + use crate::routes::messages::{amessages, messages}; + #[pymodule_export] + use crate::routes::ocr::{aocr, ocr}; + #[pymodule_export] + use crate::routes::responses::ResponsesWebSocketConnection; + #[pymodule_export] + use crate::token_counter::TokenCounter; +} - #[pymodule_init] - fn init(module: &Bound<'_, PyModule>) -> PyResult<()> { - super::errors::register(module)?; - super::routes::register(module)?; - module.add_class::()?; - super::token_counter::register(module)?; - super::diagnostics::register(module) - } +use pyo3::prelude::*; + +#[cfg(test)] +pub(crate) fn native_module(py: Python<'_>) -> Bound<'_, PyModule> { + pyo3::wrap_pymodule!(_native)(py).into_bound(py) } #[cfg(test)] mod tests { - use std::ffi::CString; - use std::time::Duration; - - use futures_util::{SinkExt, StreamExt}; - use pyo3::types::PyDict; - use tokio::net::TcpListener; - use tokio_tungstenite::{accept_async, tungstenite::Message}; - use super::*; #[test] fn module_registration_preserves_the_public_surface() { Python::initialize(); Python::attach(|py| { - let module = pyo3::wrap_pymodule!(_native)(py).into_bound(py); - - let expected = [ + let mut expected = vec![ "RustBridgeDeclined", "RustUpstreamError", "ocr", @@ -115,8 +61,9 @@ mod tests { "TokenCounter", "gil_stats", ]; + expected.sort_unstable(); - let public_names: Vec = module + let mut public_names: Vec = native_module(py) .dict() .keys() .extract::>() @@ -124,71 +71,8 @@ mod tests { .into_iter() .filter(|name| !name.starts_with('_')) .collect(); + public_names.sort_unstable(); assert_eq!(public_names, expected); }); } - - #[test] - fn responses_websocket_connection_round_trips_through_python() { - Python::initialize(); - let runtime = pyo3_async_runtimes::tokio::get_runtime(); - let listener = runtime - .block_on(TcpListener::bind("127.0.0.1:0")) - .expect("listener should bind"); - let address = listener - .local_addr() - .expect("listener should have an address"); - let server = runtime.spawn(async move { - let (stream, _) = listener.accept().await.expect("server should accept"); - let mut socket = accept_async(stream) - .await - .expect("handshake should succeed"); - - let message = socket - .next() - .await - .expect("client should send a frame") - .expect("client frame should be valid"); - assert_eq!(message, Message::Text("from-python".into())); - socket - .send(Message::Text("from-server".into())) - .await - .expect("server should reply"); - assert!(matches!(socket.next().await, Some(Ok(Message::Close(_))))); - }); - - Python::attach(|py| { - let module = pyo3::wrap_pymodule!(_native)(py).into_bound(py); - let locals = PyDict::new(py); - locals - .set_item("native", &module) - .expect("module should enter Python locals"); - locals - .set_item("url", format!("ws://{address}")) - .expect("URL should enter Python locals"); - let code = CString::new( - r#" -import asyncio - -async def exercise(): - connection = await native.ResponsesWebSocketConnection.connect(url) - assert type(connection) is native.ResponsesWebSocketConnection - await connection.send_text("from-python") - assert await connection.recv_text() == "from-server" - await connection.close() - assert await connection.recv_text() is None - -asyncio.run(asyncio.wait_for(exercise(), timeout=5)) -"#, - ) - .expect("Python source should not contain null bytes"); - py.run(&code, Some(&locals), Some(&locals)) - .expect("Python WebSocket methods should round trip"); - }); - - runtime - .block_on(async { tokio::time::timeout(Duration::from_secs(5), server).await }) - .expect("server should finish") - .expect("server task should not panic"); - } } diff --git a/litellm-rust/crates/python-bridge/src/lifecycle/bindings.rs b/litellm-rust/crates/python-bridge/src/lifecycle/bindings.rs deleted file mode 100644 index 06b32b67fd5..00000000000 --- a/litellm-rust/crates/python-bridge/src/lifecycle/bindings.rs +++ /dev/null @@ -1,391 +0,0 @@ -use pyo3::exceptions::PyBaseException; -use pyo3::gc::{PyTraverseError, PyVisit}; -use pyo3::prelude::*; -use pyo3::types::{PyDict, PyTuple}; - -#[derive(FromPyObject)] -pub(crate) struct PythonLogger(Py); - -impl PythonLogger { - pub(crate) fn object<'py>(&self, py: Python<'py>) -> &Bound<'py, PyAny> { - self.0.bind(py) - } - - pub(crate) fn clone_ref(&self, py: Python<'_>) -> Self { - Self(self.0.clone_ref(py)) - } - - pub(crate) fn traverse(&self, visit: &PyVisit<'_>) -> Result<(), PyTraverseError> { - visit.call(&self.0) - } - - pub(crate) fn callbacks_needed(&self, py: Python<'_>, phase: &str) -> PyResult { - if !self - .object(py) - .getattr("_native_callback_fast_path") - .is_ok_and(|value| value.is_truthy().unwrap_or(false)) - { - return Ok(true); - } - py.import("litellm.rust_bridge.lifecycle")? - .getattr("callbacks_needed")? - .call1((self.object(py), phase))? - .extract() - } - - pub(super) fn success_bookkeeping( - &self, - py: Python<'_>, - response: &Option>, - start: &Py, - end: &Option>, - asynchronous: bool, - ) -> PyResult<()> { - py.import("litellm.rust_bridge.lifecycle")? - .getattr("success_bookkeeping")? - .call1((self.object(py), response, start, end, asynchronous))?; - Ok(()) - } - - pub(super) fn defers_async_logging(&self, py: Python<'_>) -> bool { - self.object(py) - .getattr("_defer_async_logging") - .is_ok_and(|value| value.is_truthy().unwrap_or(false)) - } - - pub(super) fn defer_success( - &self, - py: Python<'_>, - pending: Py, - ) -> PyResult<()> { - self.object(py).setattr("_native_pending_logging", pending) - } - - pub(super) fn sync_success_for_async_call( - &self, - py: Python<'_>, - response: &Option>, - start: &Py, - end: &Option>, - ) -> PyResult<()> { - if !self.callbacks_needed(py, "sync_success_async")? { - return Ok(()); - } - self.object(py).call_method1( - "handle_sync_success_callbacks_for_async_calls", - (response, start, end), - )?; - Ok(()) - } - - pub(super) fn failure( - &self, - py: Python<'_>, - error: &Py, - start: &Py, - end: &Option>, - asynchronous: bool, - ) -> PyResult>> { - if !self.callbacks_needed( - py, - if asynchronous { - "async_failure" - } else { - "sync_failure" - }, - )? { - py.import("litellm.rust_bridge.lifecycle")? - .getattr("failure_bookkeeping")? - .call1((self.object(py), error, start, end, asynchronous))?; - return Ok(None); - } - let trace = py - .import("traceback")? - .getattr("format_exception")? - .call1((error,))?; - let trace = pyo3::types::PyString::new(py, "").call_method1("join", (trace,))?; - let value = self.object(py).call_method1( - if asynchronous { - "async_failure_handler" - } else { - "failure_handler" - }, - (error, trace, start, end), - )?; - Ok(asynchronous.then(|| value.unbind())) - } - - pub(super) fn restore_context(&self, py: Python<'_>) -> PyResult<()> { - py.import("litellm.utils")? - .getattr("_restore_correlation_context_if_supported")? - .call1((self.object(py),))?; - Ok(()) - } - - pub(super) fn submit_success( - &self, - py: Python<'_>, - response: &Option>, - start: &Py, - end: &Option>, - ) -> PyResult<()> { - if !self.callbacks_needed(py, "sync_success")? { - return self.success_bookkeeping(py, response, start, end, false); - } - let context = py.import("contextvars")?.call_method0("copy_context")?; - py.import("litellm.litellm_core_utils.litellm_logging")? - .getattr("executor")? - .call_method1( - "submit", - ( - context.getattr("run")?, - self.object(py).getattr("success_handler")?, - response, - start, - end, - ), - )?; - Ok(()) - } - - pub(super) fn enqueue_success( - &self, - py: Python<'_>, - response: &Option>, - start: &Py, - end: &Option>, - ) -> PyResult<()> { - if !self.callbacks_needed(py, "async_success")? { - return self.success_bookkeeping(py, response, start, end, true); - } - let context = py.import("contextvars")?.call_method0("copy_context")?; - let worker = py - .import("litellm.litellm_core_utils.logging_worker")? - .getattr("GLOBAL_LOGGING_WORKER")? - .getattr("ensure_initialized_and_enqueue")?; - let coroutine = self - .object(py) - .call_method1("async_success_handler", (response, start, end))?; - let enqueue = context.call_method1("run", (worker, &coroutine)); - if enqueue.is_err() - && let Err(error) = coroutine.call_method0("close") - { - error.write_unraisable(py, Some(&coroutine)); - } - enqueue.map(|_| ()) - } -} - -pub(super) struct SetupResult<'py>(Bound<'py, PyAny>); - -impl SetupResult<'_> { - pub(super) fn logger(&self) -> PyResult { - self.0.getattr("logger")?.extract() - } - - pub(super) fn kwargs(&self) -> PyResult> { - Ok(self.0.getattr("kwargs")?.extract()?) - } -} - -pub(super) fn setup<'py>( - py: Python<'py>, - call_type: &str, - args: &Py, - kwargs: &Py, - start: &Py, - asynchronous: bool, -) -> PyResult> { - py.import("litellm.rust_bridge.lifecycle")? - .getattr("setup")? - .call1((call_type, args, kwargs, start, asynchronous)) - .map(SetupResult) -} - -pub(super) fn finalize( - py: Python<'_>, - response: &Option>, - logger: &PythonLogger, - kwargs: &Py, - start: &Py, - end: &Option>, -) -> PyResult<()> { - py.import("litellm.rust_bridge.lifecycle")? - .getattr("finalize")? - .call1((response, logger.object(py), kwargs, start, end))?; - Ok(()) -} - -pub(super) fn is_internal_call(py: Python<'_>) -> PyResult { - py.import("litellm._internal_context")? - .getattr("is_internal_call")? - .call_method0("get")? - .extract() -} - -pub(super) struct DeploymentHooks; - -impl DeploymentHooks { - pub(super) fn needed(py: Python<'_>) -> PyResult { - py.import("litellm.rust_bridge.lifecycle")? - .getattr("deployment_callbacks_needed")? - .call0()? - .extract() - } - - pub(super) fn before_call( - py: Python<'_>, - kwargs: &Py, - call_type: &str, - ) -> PyResult> { - py.import("litellm.utils")? - .getattr("async_pre_call_deployment_hook")? - .call1((kwargs, call_type)) - .map(Bound::unbind) - } - - pub(super) fn after_success( - py: Python<'_>, - kwargs: &Py, - response: &Option>, - call_type: &str, - ) -> PyResult> { - py.import("litellm.utils")? - .getattr("async_post_call_success_deployment_hook")? - .call1((kwargs, response, call_type)) - .map(Bound::unbind) - } - - pub(super) fn after_failure( - py: Python<'_>, - kwargs: &Py, - error: &Py, - call_type: &str, - ) -> PyResult> { - py.import("litellm.utils")? - .getattr("async_post_call_failure_deployment_hook")? - .call1((kwargs, error, call_type)) - .map(Bound::unbind) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use pyo3::exceptions::PyTypeError; - - #[test] - fn setup_fields_are_checked_in_order_without_eager_logger_method_reads() { - Python::initialize(); - Python::attach(|py| { - let locals = PyDict::new(py); - py.run( - pyo3::ffi::c_str!( - r#" -reads = [] -class Logger: - def __getattribute__(self, name): - reads.append(name) - raise AssertionError('logger methods must remain lazy') -logger = Logger() -class Setup: - @property - def logger(self): - reads.append('logger') - return logger - @property - def kwargs(self): - reads.append('kwargs') - return [] -result = Setup() -"# - ), - Some(&locals), - Some(&locals), - ) - .unwrap(); - let result = SetupResult(locals.get_item("result").unwrap().unwrap()); - let logger = result.logger().unwrap(); - assert!( - logger - .object(py) - .is(locals.get_item("logger").unwrap().unwrap()) - ); - assert_eq!( - locals - .get_item("reads") - .unwrap() - .unwrap() - .extract::>() - .unwrap(), - ["logger"] - ); - assert!( - result - .kwargs() - .unwrap_err() - .is_instance_of::(py) - ); - assert_eq!( - locals - .get_item("reads") - .unwrap() - .unwrap() - .extract::>() - .unwrap(), - ["logger", "kwargs"] - ); - }); - } - - #[test] - fn logger_resolves_each_callback_at_invocation_and_preserves_arguments() { - Python::initialize(); - Python::attach(|py| { - let locals = PyDict::new(py); - py.run( - pyo3::ffi::c_str!( - r#" -calls = [] -response, start, end = object(), object(), object() -class Logger: - @property - def handle_sync_success_callbacks_for_async_calls(self): - generation = len(calls) - def callback(*args): - assert args == (response, start, end) - calls.append(generation) - return callback -logger = Logger() -"# - ), - Some(&locals), - Some(&locals), - ) - .unwrap(); - let logger: PythonLogger = locals - .get_item("logger") - .unwrap() - .unwrap() - .extract() - .unwrap(); - let response = Some(locals.get_item("response").unwrap().unwrap().unbind()); - let start = locals.get_item("start").unwrap().unwrap().unbind(); - let end = Some(locals.get_item("end").unwrap().unwrap().unbind()); - for _ in 0..2 { - logger - .sync_success_for_async_call(py, &response, &start, &end) - .unwrap(); - } - assert_eq!( - locals - .get_item("calls") - .unwrap() - .unwrap() - .extract::>() - .unwrap(), - [0, 1] - ); - }); - } -} diff --git a/litellm-rust/crates/python-bridge/src/lifecycle/mod.rs b/litellm-rust/crates/python-bridge/src/lifecycle/mod.rs deleted file mode 100644 index c4b8d8eaae0..00000000000 --- a/litellm-rust/crates/python-bridge/src/lifecycle/mod.rs +++ /dev/null @@ -1,1191 +0,0 @@ -use std::sync::Arc; -use std::task::Poll; - -use futures_util::future::{AbortHandle, Abortable}; -#[cfg(test)] -use litellm_core::call_lifecycle::host::HostCallFuture; -use litellm_core::call_lifecycle::host::{ - HostCall as NativeCall, HostCallStep as NativeCallStep, HostFailure, HostPhase, HostStep, -}; -use pyo3::exceptions::{PyBaseException, PyException, PyRuntimeError}; -use pyo3::gc::{PyTraverseError, PyVisit}; -use pyo3::prelude::*; -use pyo3::types::{PyDict, PyTuple}; -use tokio::sync::Mutex; - -use crate::execution::{poll_async_value, run_async_value, run_sync_value}; - -mod bindings; -mod handle; -mod preparation; - -use bindings::DeploymentHooks; -pub(crate) use bindings::PythonLogger; -use handle::{Execution, ExecutionBody, ExecutionStep}; - -pub(crate) enum OperationClass { - Phase(HostPhase), - Route, -} - -pub(crate) trait PythonRoute: Send + Sync { - type Call: NativeCall + 'static; - - fn state(&self) -> &PythonCallState; - fn state_mut(&mut self) -> &mut PythonCallState; - fn classify(operation: &::Operation) -> OperationClass; - fn lifecycle_result() -> ::Result; - fn map_error(error: ::Error) -> PyErr; - fn host_error(message: String) -> ::Error; - fn invoke( - &mut self, - py: Python<'_>, - operation: ::Operation, - ) -> PyResult<::Result>; - fn cleanup(&mut self); - fn traverse(&self, visit: &PyVisit<'_>) -> Result<(), PyTraverseError>; -} - -type NativeStep = NativeCallStep<::Operation, ::Complete>; -type NativeResult = Result, ::Error>; -type HostResumeStep = HostStep::Call>, Py>; -type NativeResume = - Option::Result, HostFailure<::Error>>>; - -struct NativeCallState { - call: C, - result: Option>, -} - -enum PendingOperation { - Native, - Host(HostPhase), -} - -struct PythonLifecycle { - route: R, - call: Option>>>, - pending: Option, - native_abort: Option, -} - -pub(crate) fn run_call( - py: Python<'_>, - call: R::Call, - route: R, -) -> PyResult> { - let asynchronous = route.state().asynchronous; - let mut lifecycle = PythonLifecycle { - route, - call: Some(Arc::new(Mutex::new(NativeCallState { call, result: None }))), - pending: None, - native_abort: None, - }; - if asynchronous { - let execution = Py::new(py, Execution::new(lifecycle))?; - return py - .import("litellm.rust_bridge.lifecycle")? - .getattr("drive")? - .call1((execution,)) - .map(Bound::unbind); - } - match lifecycle.resume(None)? { - ExecutionStep::Return(value) => Ok(value), - ExecutionStep::Await(_) => Err(pyo3::exceptions::PyRuntimeError::new_err( - "sync call suspended", - )), - } -} - -pub(crate) fn missing_state() -> PyErr { - pyo3::exceptions::PyRuntimeError::new_err("missing native call state") -} - -impl PythonLifecycle { - fn resume_core( - &mut self, - py: Python<'_>, - result: NativeResume, - ) -> PyResult> { - let call = Arc::clone(self.call.as_ref().ok_or_else(missing_state)?); - let future = async move { - let mut call = call.lock().await; - let result = match result { - Some(Err(failure)) => call.call.interrupt(failure).await, - Some(Ok(result)) => call.call.resume(Some(result)).await, - None => call.call.resume(None).await, - }; - call.result = Some(result); - Ok(()) - }; - if self.route.state().asynchronous { - let mut future = Box::pin(future); - if let Poll::Ready(()) = poll_async_value(py, future.as_mut())? { - return Ok(HostStep::Ready(self.take_native_result()?)); - } - let (abort, registration) = AbortHandle::new_pair(); - self.native_abort = Some(abort); - self.pending = Some(PendingOperation::Native); - Ok(HostStep::Suspend( - run_async_value(py, async move { - Abortable::new(future, registration) - .await - .map_err(|_| PyRuntimeError::new_err("native execution closed"))? - })? - .unbind(), - )) - } else { - run_sync_value(py, future)?; - Ok(HostStep::Ready(self.take_native_result()?)) - } - } - - fn take_native_result(&self) -> PyResult> { - self.call - .as_ref() - .ok_or_else(missing_state)? - .try_lock() - .map_err(|_| missing_state())? - .result - .take() - .ok_or_else(missing_state)? - .map_err(R::map_error) - } - - fn host_failure( - &mut self, - py: Python<'_>, - error: PyErr, - phase: Option, - ) -> HostFailure<::Error> { - let native = R::host_error(error.to_string()); - let cancelled = !error.is_instance_of::(py); - let failure = if !cancelled { - HostFailure::Error(native) - } else { - HostFailure::Cancelled(native) - }; - let state = self.route.state_mut(); - if state.error.is_none() || (cancelled && phase != Some(HostPhase::DeploymentFailure)) { - state.retain_error(py, error); - } - if state.end.is_none() { - state.end = now(py).ok(); - } - failure - } - - fn drive( - &mut self, - py: Python<'_>, - result: Option>>, - ) -> PyResult { - let mut step = match (self.pending.take(), result) { - (None, None) => self.resume_core(py, None)?, - (Some(PendingOperation::Native), Some(result)) => match result { - Ok(_) => HostStep::Ready(self.take_native_result()?), - Err(error) => { - let failure = self.host_failure(py, error, None); - self.resume_core(py, Some(Err(failure)))? - } - }, - (Some(PendingOperation::Host(phase)), Some(result)) => { - let result = - result.and_then(|value| self.route.state_mut().accept(py, phase, value)); - let result = match result { - Ok(()) => Ok(R::lifecycle_result()), - Err(error) => Err(self.host_failure(py, error, Some(phase))), - }; - self.resume_core(py, Some(result))? - } - _ => return Err(missing_state()), - }; - loop { - let operation = match step { - HostStep::Suspend(awaitable) => return Ok(ExecutionStep::Await(awaitable)), - HostStep::Ready(NativeCallStep::Complete(_)) => { - return self - .route - .state_mut() - .response - .take() - .map(ExecutionStep::Return) - .ok_or_else(missing_state); - } - HostStep::Ready(NativeCallStep::Host(operation)) => operation, - }; - let phase = match R::classify(&operation) { - OperationClass::Phase(phase) => Some(phase), - OperationClass::Route => None, - }; - let result = match phase { - Some(phase) => match self.route.state_mut().invoke(py, phase) { - Ok(HostStep::Suspend(awaitable)) => { - self.pending = Some(PendingOperation::Host(phase)); - return Ok(ExecutionStep::Await(awaitable)); - } - Ok(HostStep::Ready(value)) => self - .route - .state_mut() - .accept(py, phase, value) - .map(|()| R::lifecycle_result()), - Err(error) => Err(error), - }, - None => self.route.invoke(py, operation), - }; - let result = match result { - Ok(result) => Ok(result), - Err(error) => Err(self.host_failure(py, error, phase)), - }; - step = self.resume_core(py, Some(result))?; - } - } -} - -impl ExecutionBody for PythonLifecycle { - fn resume(&mut self, result: Option>>) -> PyResult { - let result = Python::attach(|py| self.drive(py, result)); - match result { - Ok(ExecutionStep::Await(value)) => Ok(ExecutionStep::Await(value)), - result => result.map_err(|error| { - Python::attach(|py| { - self.route - .state_mut() - .error - .take() - .map(|value| PyErr::from_value(value.into_bound(py).into_any())) - .unwrap_or(error) - }) - }), - } - } - - fn traverse(&self, visit: &PyVisit<'_>) -> Result<(), PyTraverseError> { - self.route.state().traverse(visit)?; - self.route.traverse(visit) - } -} - -impl PythonLifecycle { - fn clear(&mut self) { - if let Some(abort) = self.native_abort.take() { - abort.abort(); - } - if self.call.take().is_some() { - Python::attach(|py| self.route.state_mut().cleanup(py)); - self.route.cleanup(); - } - } -} - -impl Drop for PythonLifecycle { - fn drop(&mut self) { - self.clear(); - } -} - -pub(crate) struct PythonCallState { - pub args: Py, - pub kwargs: Py, - pub logger: Option, - pub start: Py, - pub end: Option>, - pub response: Option>, - pub error: Option>, - pub asynchronous: bool, - pub internal: bool, - pub call_type: &'static str, -} - -pub(crate) fn now(py: Python<'_>) -> PyResult> { - py.import("datetime")? - .getattr("datetime")? - .call_method0("now") - .map(Bound::unbind) -} - -impl PythonCallState { - fn invoke( - &mut self, - py: Python<'_>, - phase: HostPhase, - ) -> PyResult, Py>> { - match phase { - HostPhase::Setup => self.setup(py)?, - HostPhase::DeploymentPreCall => { - if !DeploymentHooks::needed(py)? { - return Ok(HostStep::Ready(self.kwargs.clone_ref(py).into_any())); - } - return Ok(HostStep::Suspend(DeploymentHooks::before_call( - py, - &self.kwargs, - self.call_type, - )?)); - } - HostPhase::Prepare => self.prepare(py)?, - HostPhase::DeploymentPostCall => { - if !DeploymentHooks::needed(py)? { - return self - .response - .as_ref() - .map(|value| HostStep::Ready(value.clone_ref(py))) - .ok_or_else(missing_state); - } - return Ok(HostStep::Suspend(DeploymentHooks::after_success( - py, - &self.kwargs, - &self.response, - self.call_type, - )?)); - } - HostPhase::Finalize => self.finalize(py)?, - HostPhase::Success => self.dispatch_success(py)?, - HostPhase::DeploymentFailure => { - if let Some(error) = &self.error - && DeploymentHooks::needed(py)? - { - return Ok(HostStep::Suspend(DeploymentHooks::after_failure( - py, - &self.kwargs, - error, - self.call_type, - )?)); - } - } - HostPhase::Failure | HostPhase::AsyncFailure => { - if let Some(awaitable) = - self.dispatch_failure(py, phase == HostPhase::AsyncFailure)? - { - return Ok(HostStep::Suspend(awaitable)); - } - } - HostPhase::Execute - | HostPhase::ConstructResponse - | HostPhase::MapFailure - | HostPhase::Complete => return Err(missing_state()), - } - Ok(HostStep::Ready(py.None())) - } - - fn accept(&mut self, py: Python<'_>, phase: HostPhase, value: Py) -> PyResult<()> { - match phase { - HostPhase::DeploymentPreCall => { - self.kwargs = value.into_bound(py).cast_into::()?.unbind() - } - HostPhase::DeploymentPostCall => self.response = Some(value), - _ => {} - } - Ok(()) - } - - pub fn new( - py: Python<'_>, - args: Py, - kwargs: Py, - asynchronous: bool, - call_type: &'static str, - ) -> PyResult { - Ok(Self { - args, - kwargs, - logger: None, - start: py.None(), - end: None, - response: None, - error: None, - asynchronous, - internal: false, - call_type, - }) - } - - pub fn logger(&self) -> PyResult<&PythonLogger> { - self.logger.as_ref().ok_or_else(|| { - pyo3::exceptions::PyRuntimeError::new_err("call logging is not initialized") - }) - } - - pub fn setup(&mut self, py: Python<'_>) -> PyResult<()> { - self.start = now(py)?; - self.internal = bindings::is_internal_call(py)?; - let result = bindings::setup( - py, - self.call_type, - &self.args, - &self.kwargs, - &self.start, - self.asynchronous, - )?; - self.logger = Some(result.logger()?); - self.kwargs = result.kwargs()?; - Ok(()) - } - - pub fn prepare(&mut self, py: Python<'_>) -> PyResult<()> { - self.kwargs = preparation::prepare(py, self.kwargs.bind(py), self.logger()?)?.unbind(); - Ok(()) - } - - pub fn finalize(&self, py: Python<'_>) -> PyResult<()> { - bindings::finalize( - py, - &self.response, - self.logger()?, - &self.kwargs, - &self.start, - &self.end, - ) - } - - pub fn dispatch_success(&self, py: Python<'_>) -> PyResult<()> { - match self.try_dispatch_success(py) { - Err(error) if error.is_instance_of::(py) => { - error.write_unraisable(py, self.logger.as_ref().map(|logger| logger.object(py))); - Ok(()) - } - result => result, - } - } - - fn try_dispatch_success(&self, py: Python<'_>) -> PyResult<()> { - let logger = self.logger()?; - let pending = || PendingSuccess { - logger: logger.clone_ref(py), - response: self.response.as_ref().map(|value| value.clone_ref(py)), - start: self.start.clone_ref(py), - end: self.end.as_ref().map(|value| value.clone_ref(py)), - }; - if !self.asynchronous { - if !logger.callbacks_needed(py, "sync_success")? { - return logger.success_bookkeeping( - py, - &self.response, - &self.start, - &self.end, - false, - ); - } - pending().sync(py) - } else { - if !self.internal - && self - .kwargs - .bind(py) - .get_item("fallbacks")? - .is_none_or(|value| value.is_none()) - { - if !logger.callbacks_needed(py, "async_success")? { - logger.success_bookkeeping(py, &self.response, &self.start, &self.end, true)?; - } else if logger.defers_async_logging(py) { - logger.defer_success( - py, - Py::new( - py, - PendingLogging { - pending: Some(pending()), - }, - )?, - )?; - } else { - pending().asynchronous(py)?; - } - } - logger.sync_success_for_async_call(py, &self.response, &self.start, &self.end) - } - } - - pub fn dispatch_failure( - &self, - py: Python<'_>, - asynchronous: bool, - ) -> PyResult>> { - if self.logger.is_none() || (self.asynchronous && self.internal) { - return Ok(None); - } - let Some(error) = &self.error else { - return Ok(None); - }; - self.logger()? - .failure(py, error, &self.start, &self.end, asynchronous) - } - - pub fn cleanup(&mut self, py: Python<'_>) { - if let Some(logger) = self.logger.take() - && let Err(error) = logger.restore_context(py) - { - error.write_unraisable(py, None); - } - } - - pub fn retain_error(&mut self, py: Python<'_>, error: PyErr) { - self.error = Some(error.into_value(py)); - } - - pub fn traverse(&self, visit: &PyVisit<'_>) -> Result<(), PyTraverseError> { - visit.call(&self.args)?; - visit.call(&self.kwargs)?; - if let Some(logger) = &self.logger { - logger.traverse(visit)?; - } - visit.call(&self.start)?; - visit.call(&self.end)?; - visit.call(&self.response)?; - visit.call(&self.error) - } -} - -struct PendingSuccess { - logger: PythonLogger, - response: Option>, - start: Py, - end: Option>, -} - -impl PendingSuccess { - fn sync(&self, py: Python<'_>) -> PyResult<()> { - self.logger - .submit_success(py, &self.response, &self.start, &self.end) - } - - fn asynchronous(&self, py: Python<'_>) -> PyResult<()> { - self.logger - .enqueue_success(py, &self.response, &self.start, &self.end) - } -} - -#[pyclass] -struct PendingLogging { - pending: Option, -} - -#[pymethods] -impl PendingLogging { - fn release(slf: &Bound<'_, Self>, py: Python<'_>, success: bool) -> PyResult<()> { - let pending = slf.borrow_mut().pending.take(); - if let Some(pending) = pending - && success - { - match pending.asynchronous(py) { - Err(error) if error.is_instance_of::(py) => { - error.write_unraisable(py, Some(pending.logger.object(py))); - } - result => return result, - } - } - Ok(()) - } - - fn __traverse__(&self, visit: pyo3::gc::PyVisit<'_>) -> Result<(), pyo3::gc::PyTraverseError> { - if let Some(pending) = &self.pending { - pending.logger.traverse(&visit)?; - visit.call(&pending.response)?; - visit.call(&pending.start)?; - visit.call(&pending.end)?; - } - Ok(()) - } - - fn __clear__(slf: &Bound<'_, Self>) { - let pending = slf.borrow_mut().pending.take(); - drop(pending); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use pyo3::types::PyDict; - use std::sync::Mutex; - - static PYTHON_GLOBALS: Mutex<()> = Mutex::new(()); - - fn install_lifecycle_module(py: Python<'_>) -> Bound<'_, PyModule> { - py.run( - pyo3::ffi::c_str!( - r#" -import sys -import types - -sys.modules.setdefault('litellm', types.ModuleType('litellm')) -sys.modules.setdefault('litellm.rust_bridge', types.ModuleType('litellm.rust_bridge')) -"# - ), - None, - None, - ) - .unwrap(); - let source = std::ffi::CString::new(include_str!( - "../../../../../litellm/rust_bridge/lifecycle.py" - )) - .unwrap(); - PyModule::from_code( - py, - &source, - pyo3::ffi::c_str!("lifecycle.py"), - pyo3::ffi::c_str!("litellm.rust_bridge.lifecycle"), - ) - .unwrap() - } - - fn install_logging_worker(py: Python<'_>, worker: &Bound<'_, PyAny>) -> PyResult<()> { - py.import("litellm.litellm_core_utils.logging_worker")? - .setattr("GLOBAL_LOGGING_WORKER", worker) - } - - struct RetainingHost { - retained: Option>, - } - - impl ExecutionBody for RetainingHost { - fn resume(&mut self, _: Option>>) -> PyResult { - Python::attach(|py| Ok(ExecutionStep::Return(py.None()))) - } - - fn traverse(&self, visit: &PyVisit<'_>) -> Result<(), PyTraverseError> { - visit.call(&self.retained) - } - } - - #[pyfunction] - fn retaining_coroutine(py: Python<'_>, retained: Py) -> PyResult> { - Py::new( - py, - Execution::new(RetainingHost { - retained: Some(retained), - }), - ) - } - - struct AwaitBody(Option>); - - impl ExecutionBody for AwaitBody { - fn resume(&mut self, result: Option>>) -> PyResult { - match self.0.take() { - Some(awaitable) => Ok(ExecutionStep::Await(awaitable)), - None => result - .expect("selected await completed") - .map(ExecutionStep::Return), - } - } - - fn traverse(&self, visit: &PyVisit<'_>) -> Result<(), PyTraverseError> { - visit.call(&self.0) - } - } - - #[pyfunction] - fn await_execution(awaitable: Py) -> Execution { - Execution::new(AwaitBody(Some(awaitable))) - } - - struct CallingBody(Py); - - impl ExecutionBody for CallingBody { - fn resume(&mut self, _: Option>>) -> PyResult { - Python::attach(|py| self.0.call0(py).map(ExecutionStep::Return)) - } - - fn traverse(&self, visit: &PyVisit<'_>) -> Result<(), PyTraverseError> { - visit.call(&self.0) - } - } - - #[pyfunction] - fn calling_execution(callback: Py) -> Execution { - Execution::new(CallingBody(callback)) - } - - struct SyntheticCall(bool); - - impl NativeCall for SyntheticCall { - type Error = litellm_core::messages::Error; - type Operation = (); - type Result = (); - type Complete = (); - - fn resume( - &mut self, - result: Option, - ) -> HostCallFuture<'_, Self::Operation, Self::Complete, Self::Error> { - Box::pin(async move { - match (self.0, result) { - (false, None) => { - self.0 = true; - Ok(NativeCallStep::Host(())) - } - (true, Some(())) => Ok(NativeCallStep::Complete(())), - _ => Err(litellm_core::messages::Error::InvalidRequest( - "invalid synthetic lifecycle state".into(), - )), - } - }) - } - - fn interrupt( - &mut self, - _: HostFailure, - ) -> HostCallFuture<'_, Self::Operation, Self::Complete, Self::Error> { - Box::pin(async { Ok(NativeCallStep::Complete(())) }) - } - } - - struct SyntheticRoute(PythonCallState); - - impl PythonRoute for SyntheticRoute { - type Call = SyntheticCall; - - fn state(&self) -> &PythonCallState { - &self.0 - } - - fn state_mut(&mut self) -> &mut PythonCallState { - &mut self.0 - } - - fn classify(_: &()) -> OperationClass { - OperationClass::Route - } - - fn lifecycle_result() {} - - fn map_error(error: litellm_core::messages::Error) -> PyErr { - crate::errors::messages_error_to_pyerr(error) - } - - fn host_error(message: String) -> litellm_core::messages::Error { - litellm_core::messages::Error::InvalidRequest(message) - } - - fn invoke(&mut self, py: Python<'_>, _: ()) -> PyResult<()> { - self.0.response = Some( - pyo3::types::PyString::new(py, "shared lifecycle") - .into_any() - .unbind(), - ); - Ok(()) - } - - fn cleanup(&mut self) {} - - fn traverse(&self, _: &PyVisit<'_>) -> Result<(), PyTraverseError> { - Ok(()) - } - } - - #[test] - fn shared_runner_executes_a_non_ocr_adapter() { - Python::initialize(); - Python::attach(|py| { - let route = SyntheticRoute( - PythonCallState::new( - py, - PyTuple::empty(py).unbind(), - PyDict::new(py).unbind(), - false, - "synthetic", - ) - .unwrap(), - ); - let value: String = run_call(py, SyntheticCall(false), route) - .unwrap() - .extract(py) - .unwrap(); - assert_eq!(value, "shared lifecycle"); - }); - } - - #[test] - fn ready_native_lifecycle_completes_without_scheduling() { - let _guard = PYTHON_GLOBALS - .lock() - .unwrap_or_else(|error| error.into_inner()); - Python::initialize(); - Python::attach(|py| { - install_lifecycle_module(py); - let route = SyntheticRoute( - PythonCallState::new( - py, - PyTuple::empty(py).unbind(), - PyDict::new(py).unbind(), - true, - "synthetic", - ) - .unwrap(), - ); - let coroutine = run_call(py, SyntheticCall(false), route).unwrap(); - let completed = coroutine - .call_method1(py, "send", (py.None(),)) - .unwrap_err(); - assert!(completed.is_instance_of::(py)); - assert_eq!( - completed - .value(py) - .getattr("value") - .unwrap() - .extract::() - .unwrap(), - "shared lifecycle", - ); - }); - } - - #[test] - fn python_driver_preserves_inline_await_and_native_ownership() { - let _guard = PYTHON_GLOBALS - .lock() - .unwrap_or_else(|error| error.into_inner()); - Python::initialize(); - Python::attach(|py| { - py.import("asyncio").unwrap(); - let module = install_lifecycle_module(py); - let locals = PyDict::new(py); - locals - .set_item("drive", module.getattr("drive").unwrap()) - .unwrap(); - locals - .set_item( - "await_execution", - wrap_pyfunction!(await_execution, py).unwrap(), - ) - .unwrap(); - locals - .set_item( - "calling_execution", - wrap_pyfunction!(calling_execution, py).unwrap(), - ) - .unwrap(); - let probe = std::ffi::CString::new(include_str!("../../tests/lifecycle.py")).unwrap(); - py.run(&probe, Some(&locals), Some(&locals)).unwrap(); - }); - } - - struct ErrorBody(PythonCallState); - - impl ExecutionBody for ErrorBody { - fn resume(&mut self, _: Option>>) -> PyResult { - Python::attach(|py| { - Err(PyErr::from_value( - self.0.error.take().unwrap().into_bound(py).into_any(), - )) - }) - } - - fn traverse(&self, visit: &PyVisit<'_>) -> Result<(), PyTraverseError> { - self.0.traverse(visit) - } - } - - #[pyfunction] - fn error_execution(py: Python<'_>, error: Bound<'_, PyBaseException>) -> Execution { - let mut state = PythonCallState::new( - py, - PyTuple::empty(py).unbind(), - PyDict::new(py).unbind(), - true, - "test", - ) - .unwrap(); - state.retain_error(py, PyErr::from_value(error.into_any())); - Execution::new(ErrorBody(state)) - } - - #[test] - fn retained_exception_frames_and_duplicate_argument_edges_are_collectable() { - Python::initialize(); - Python::attach(|py| { - let locals = PyDict::new(py); - locals - .set_item( - "error_execution", - wrap_pyfunction!(error_execution, py).unwrap(), - ) - .unwrap(); - py.run( - pyo3::ffi::c_str!( - r#" -import gc -import weakref - -class Retained: - pass - -def cycle(): - retained = Retained() - try: - raise ValueError('retained traceback') - except ValueError as error: - retained.owner = error_execution(error) - return weakref.ref(retained) - -reference = cycle() -gc.collect() -assert reference() is None -"# - ), - Some(&locals), - Some(&locals), - ) - .unwrap(); - }); - } - - fn state( - py: Python<'_>, - logger: Py, - response: Py, - asynchronous: bool, - ) -> PythonCallState { - PythonCallState { - args: PyTuple::empty(py).unbind(), - kwargs: PyDict::new(py).unbind(), - logger: Some(logger.extract(py).unwrap()), - start: py.None(), - end: Some(py.None()), - response: Some(response), - error: None, - asynchronous, - internal: false, - call_type: "test", - } - } - - #[test] - fn success_dispatch_reports_ordinary_failures_without_replacing_response() { - let _guard = PYTHON_GLOBALS - .lock() - .unwrap_or_else(|error| error.into_inner()); - Python::initialize(); - Python::attach(|py| { - let locals = PyDict::new(py); - py.run( - pyo3::ffi::c_str!( - r#" -import sys - -response = object() -failure = ValueError('terminal diagnostic') -diagnostics = [] -old_hook = sys.unraisablehook -sys.unraisablehook = lambda event: diagnostics.append(event.exc_value) - -class Logger: - def handle_sync_success_callbacks_for_async_calls(self, *args): - raise failure - -logger = Logger() -"# - ), - Some(&locals), - Some(&locals), - ) - .unwrap(); - let response = locals.get_item("response").unwrap().unwrap().unbind(); - let mut lifecycle_state = state( - py, - locals.get_item("logger").unwrap().unwrap().unbind(), - response.clone_ref(py), - true, - ); - lifecycle_state.internal = true; - lifecycle_state.dispatch_success(py).unwrap(); - assert!(lifecycle_state.response.as_ref().unwrap().is(&response)); - py.run( - pyo3::ffi::c_str!( - r#" -assert diagnostics == [failure] -sys.unraisablehook = old_hook -"# - ), - Some(&locals), - Some(&locals), - ) - .unwrap(); - }); - } - - #[test] - fn retained_failure_preserves_exception_identity() { - Python::initialize(); - Python::attach(|py| { - let logger = PyDict::new(py).into_any().unbind(); - let response = py.None(); - let failure = pyo3::exceptions::PyValueError::new_err("identity"); - let failure_value = failure.value(py).clone().unbind(); - let mut lifecycle_state = state(py, logger, response, false); - lifecycle_state.retain_error(py, failure); - let retained = lifecycle_state.error.take().unwrap(); - assert!(retained.is(&failure_value)); - }); - } - - #[test] - fn deferred_release_uses_release_context_and_allows_reentry_once() { - let _guard = PYTHON_GLOBALS - .lock() - .unwrap_or_else(|error| error.into_inner()); - Python::initialize(); - Python::attach(|py| { - let locals = PyDict::new(py); - py.run( - pyo3::ffi::c_str!( - r#" -import sys -import types -from contextvars import ContextVar - -litellm = types.ModuleType('litellm') -core_utils = types.ModuleType('litellm.litellm_core_utils') -logging_worker = types.ModuleType('litellm.litellm_core_utils.logging_worker') -litellm.litellm_core_utils = core_utils -core_utils.logging_worker = logging_worker -sys.modules['litellm'] = litellm -sys.modules['litellm.litellm_core_utils'] = core_utils -sys.modules['litellm.litellm_core_utils.logging_worker'] = logging_worker - -marker = ContextVar('marker', default='unset') -observed = [] - -class Coroutine: - def close(self): - observed.append('closed') - -class Worker: - def ensure_initialized_and_enqueue(self, coroutine): - observed.append(marker.get()) - pending.release(True) - coroutine.close() - -class Logger: - def async_success_handler(self, *args): - observed.append('created') - return Coroutine() - -worker = Worker() -logger = Logger() -"# - ), - Some(&locals), - Some(&locals), - ) - .unwrap(); - install_logging_worker(py, &locals.get_item("worker").unwrap().unwrap()).unwrap(); - let pending = Py::new( - py, - PendingLogging { - pending: Some(PendingSuccess { - logger: locals - .get_item("logger") - .unwrap() - .unwrap() - .extract() - .unwrap(), - response: Some(py.None()), - start: py.None(), - end: Some(py.None()), - }), - }, - ) - .unwrap(); - locals.set_item("pending", &pending).unwrap(); - py.run( - pyo3::ffi::c_str!( - r#" -marker.set('release') -pending.release(True) -pending.release(True) -assert observed == ['created', 'release', 'closed'] -"# - ), - Some(&locals), - Some(&locals), - ) - .unwrap(); - }); - } - - #[test] - fn deferred_logging_collects_cycles_through_typed_logger() { - Python::initialize(); - Python::attach(|py| { - let locals = PyDict::new(py); - py.run( - pyo3::ffi::c_str!("class Logger: pass\nlogger = Logger()"), - Some(&locals), - Some(&locals), - ) - .unwrap(); - let pending = Py::new( - py, - PendingLogging { - pending: Some(PendingSuccess { - logger: locals - .get_item("logger") - .unwrap() - .unwrap() - .extract() - .unwrap(), - response: None, - start: py.None(), - end: None, - }), - }, - ) - .unwrap(); - locals.set_item("pending", pending).unwrap(); - py.run( - pyo3::ffi::c_str!( - r#" -import gc -import weakref -logger.pending = pending -reference = weakref.ref(logger) -del logger, pending -gc.collect() -assert reference() is None -"# - ), - Some(&locals), - Some(&locals), - ) - .unwrap(); - }); - } - - #[test] - fn coroutine_collects_cycles_retained_by_bridge_host() { - Python::initialize(); - Python::attach(|py| { - let locals = PyDict::new(py); - locals - .set_item( - "retaining_coroutine", - wrap_pyfunction!(retaining_coroutine, py).unwrap(), - ) - .unwrap(); - py.run( - pyo3::ffi::c_str!( - r#" -import gc -import weakref - -class Retained: - pass - -def cycle(): - retained = Retained() - coroutine = retaining_coroutine(retained) - retained.coroutine = coroutine - return weakref.ref(retained) - -retained_ref = cycle() -gc.collect() -assert retained_ref() is None -"# - ), - Some(&locals), - Some(&locals), - ) - .unwrap(); - }); - } -} diff --git a/litellm-rust/crates/python-bridge/src/marshal.rs b/litellm-rust/crates/python-bridge/src/marshal.rs index 7f00298905f..294c439e7e9 100644 --- a/litellm-rust/crates/python-bridge/src/marshal.rs +++ b/litellm-rust/crates/python-bridge/src/marshal.rs @@ -7,8 +7,9 @@ use pyo3::types::PyDict; use serde_json::{Map, Value}; use litellm_auth::InputSource; -use litellm_python_interop::from_py_preserving_errors as from_py; +use litellm_host_python::{from_py, from_py_argument}; +/// The keyword arguments every value route shares, validated at the Python boundary. pub(crate) struct RouteOptions { pub(crate) model: String, pub(crate) api_key: Option, @@ -18,57 +19,44 @@ pub(crate) struct RouteOptions { pub(crate) timeout: Option, } -pub(crate) struct RouteOptionsInputs { - pub(crate) model: String, - pub(crate) api_key: Option, - pub(crate) api_base: Option, - pub(crate) custom_llm_provider: Option, - pub(crate) extra_headers: Option, - pub(crate) timeout_seconds: Option, +pub(crate) fn body_argument(value: &Bound<'_, PyAny>) -> PyResult> { + required_object("body", from_py_argument(value)?) } -impl RouteOptions { - pub(crate) fn from_python(inputs: RouteOptionsInputs) -> PyResult { - Ok(Self { - model: inputs.model, - api_key: inputs.api_key, - api_base: inputs.api_base, - custom_llm_provider: inputs.custom_llm_provider, - extra_headers: optional_object("extra_headers", inputs.extra_headers)?, - timeout: optional_timeout(inputs.timeout_seconds), - }) - } -} - -pub(crate) fn required_array(name: &'static str, value: Value) -> PyResult> { - match value { +pub(crate) fn messages_argument(value: &Bound<'_, PyAny>) -> PyResult> { + match from_py_argument(value)? { Value::Array(values) => Ok(values), - _ => Err(PyValueError::new_err(format!("{name} must be a list"))), + _ => Err(PyValueError::new_err("messages must be a list")), } } -pub(crate) fn required_object(name: &'static str, value: Value) -> PyResult> { +pub(crate) fn optional_params_argument( + value: &Bound<'_, PyAny>, +) -> PyResult>> { + optional_object("optional_params", value) +} + +pub(crate) fn extra_headers_argument( + value: &Bound<'_, PyAny>, +) -> PyResult>> { + optional_object("extra_headers", value) +} + +fn required_object(name: &'static str, value: Value) -> PyResult> { match value { Value::Object(values) => Ok(values), _ => Err(PyValueError::new_err(format!("{name} must be a dict"))), } } -pub(crate) fn object_or_empty( - name: &'static str, - value: Option, -) -> PyResult> { - match value { - Some(value) => required_object(name, value), - None => Ok(Map::new()), - } -} - fn optional_object( name: &'static str, - value: Option, + value: &Bound<'_, PyAny>, ) -> PyResult>> { - value.map(|value| required_object(name, value)).transpose() + if value.is_none() { + return Ok(None); + } + required_object(name, from_py_argument(value)?).map(Some) } pub(crate) fn optional_timeout(timeout_seconds: Option) -> Option { @@ -189,42 +177,47 @@ mod tests { } #[test] - fn required_shapes_preserve_nested_values_and_existing_errors() { + fn argument_converters_keep_nested_values_and_accept_explicit_none() { Python::initialize(); - let nested = json!([{"role": "user", "content": [{"type": "text", "text": "hi"}]}]); - assert_eq!( - Value::Array(required_array("messages", nested.clone()).unwrap()), - nested - ); + Python::attach(|py| { + let messages = py + .eval( + c"[{'role': 'user', 'content': [{'type': 'text', 'text': 'hi'}]}]", + None, + None, + ) + .unwrap(); + assert_eq!( + Value::Array(messages_argument(&messages).unwrap()), + json!([{"role": "user", "content": [{"type": "text", "text": "hi"}]}]) + ); - let body = json!({"model": "claude", "metadata": {"user": "1"}}); - assert_eq!( - Value::Object(required_object("body", body.clone()).unwrap()), - body - ); + let body = py + .eval( + c"{'model': 'claude', 'metadata': {'user': '1'}}", + None, + None, + ) + .unwrap(); + assert_eq!( + Value::Object(body_argument(&body).unwrap()), + json!({"model": "claude", "metadata": {"user": "1"}}) + ); - assert_eq!( - required_array("messages", json!({"role": "user"})) - .unwrap_err() - .to_string(), - "ValueError: messages must be a list" - ); - assert_eq!( - required_object("body", json!([])).unwrap_err().to_string(), - "ValueError: body must be a dict" - ); - } - - #[test] - fn optional_parameters_treat_missing_as_empty() { - assert_eq!( - object_or_empty("optional_params", None).unwrap(), - Map::new() - ); - assert_eq!( - object_or_empty("optional_params", Some(json!({"temperature": 0.2}))).unwrap(), - required_object("optional_params", json!({"temperature": 0.2})).unwrap() - ); + let params = py.eval(c"{'temperature': 0.2}", None, None).unwrap(); + assert_eq!( + optional_params_argument(¶ms).unwrap(), + Some(required_object("optional_params", json!({"temperature": 0.2})).unwrap()) + ); + assert_eq!( + optional_params_argument(&py.None().into_bound(py)).unwrap(), + None + ); + assert_eq!( + extra_headers_argument(&py.None().into_bound(py)).unwrap(), + None + ); + }); } #[test] diff --git a/litellm-rust/crates/python-bridge/src/routes/audio_transcription.rs b/litellm-rust/crates/python-bridge/src/routes/audio_transcription.rs new file mode 100644 index 00000000000..248475b26ed --- /dev/null +++ b/litellm-rust/crates/python-bridge/src/routes/audio_transcription.rs @@ -0,0 +1,101 @@ +use litellm_core::audio_transcription::{ + AudioTranscriptionRequest, Error, audio_transcription as run_audio_transcription, +}; +use litellm_host_python::{from_py_argument, run_async, run_sync}; +use pyo3::prelude::*; +use serde_json::{Map, Value}; + +use crate::errors::audio_transcription_error_to_pyerr; +use crate::marshal::{ + RouteOptions, extra_headers_argument, optional_params_argument, optional_timeout, +}; + +async fn execute( + audio: Value, + optional_params: Map, + options: RouteOptions, +) -> Result { + let RouteOptions { + model, + api_key, + api_base, + custom_llm_provider, + extra_headers, + timeout, + } = options; + run_audio_transcription(AudioTranscriptionRequest { + model: &model, + audio, + api_key: api_key.as_deref(), + api_base: api_base.as_deref(), + custom_llm_provider: custom_llm_provider.as_deref(), + extra_headers, + optional_params, + timeout, + }) + .await +} + +#[pyfunction] +#[pyo3(signature = (model, audio, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, optional_params=None, timeout_seconds=None))] +#[expect( + clippy::too_many_arguments, + reason = "one parameter per Python keyword" +)] +pub(crate) fn transcription( + py: Python<'_>, + model: String, + #[pyo3(from_py_with = from_py_argument)] audio: Value, + api_key: Option, + api_base: Option, + custom_llm_provider: Option, + #[pyo3(from_py_with = extra_headers_argument)] extra_headers: Option>, + #[pyo3(from_py_with = optional_params_argument)] optional_params: Option>, + timeout_seconds: Option, +) -> PyResult> { + let options = RouteOptions { + model, + api_key, + api_base, + custom_llm_provider, + extra_headers, + timeout: optional_timeout(timeout_seconds), + }; + run_sync( + py, + execute(audio, optional_params.unwrap_or_default(), options), + audio_transcription_error_to_pyerr, + ) +} + +#[pyfunction] +#[pyo3(signature = (model, audio, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, optional_params=None, timeout_seconds=None))] +#[expect( + clippy::too_many_arguments, + reason = "one parameter per Python keyword" +)] +pub(crate) fn atranscription<'py>( + py: Python<'py>, + model: String, + #[pyo3(from_py_with = from_py_argument)] audio: Value, + api_key: Option, + api_base: Option, + custom_llm_provider: Option, + #[pyo3(from_py_with = extra_headers_argument)] extra_headers: Option>, + #[pyo3(from_py_with = optional_params_argument)] optional_params: Option>, + timeout_seconds: Option, +) -> PyResult> { + let options = RouteOptions { + model, + api_key, + api_base, + custom_llm_provider, + extra_headers, + timeout: optional_timeout(timeout_seconds), + }; + run_async( + py, + execute(audio, optional_params.unwrap_or_default(), options), + audio_transcription_error_to_pyerr, + ) +} diff --git a/litellm-rust/crates/python-bridge/src/routes/audio_transcription/mod.rs b/litellm-rust/crates/python-bridge/src/routes/audio_transcription/mod.rs deleted file mode 100644 index 68b701802a9..00000000000 --- a/litellm-rust/crates/python-bridge/src/routes/audio_transcription/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -mod value; - -use pyo3::prelude::*; - -pub(super) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { - value::register(module) -} diff --git a/litellm-rust/crates/python-bridge/src/routes/audio_transcription/value.rs b/litellm-rust/crates/python-bridge/src/routes/audio_transcription/value.rs deleted file mode 100644 index 5ecca63fcb6..00000000000 --- a/litellm-rust/crates/python-bridge/src/routes/audio_transcription/value.rs +++ /dev/null @@ -1,71 +0,0 @@ -use litellm_core::audio_transcription::Error; -use std::future::Future; - -use litellm_core::audio_transcription::{ - AudioTranscriptionRequest, audio_transcription as run_audio_transcription, -}; -use pyo3::prelude::*; -use serde_json::Value; - -use crate::errors::audio_transcription_error_to_pyerr; -use crate::marshal::{RouteOptions, RouteOptionsInputs, object_or_empty}; - -fn prepare_transcription( - inputs: AudioTranscriptionInputs, -) -> PyResult> + Send + 'static> { - let audio = inputs.audio; - let options = RouteOptions::from_python(RouteOptionsInputs { - model: inputs.model, - api_key: inputs.api_key, - api_base: inputs.api_base, - custom_llm_provider: inputs.custom_llm_provider, - extra_headers: inputs.extra_headers, - timeout_seconds: inputs.timeout_seconds, - })?; - let optional_params = object_or_empty("optional_params", inputs.optional_params)?; - - Ok(async move { - let RouteOptions { - model, - api_key, - api_base, - custom_llm_provider, - extra_headers, - timeout, - } = options; - run_audio_transcription(AudioTranscriptionRequest { - model: &model, - audio, - api_key: api_key.as_deref(), - api_base: api_base.as_deref(), - custom_llm_provider: custom_llm_provider.as_deref(), - extra_headers, - optional_params, - timeout, - }) - .await - }) -} - -bridge_route! { - sync = transcription, - asynchronous = atranscription, - inputs = AudioTranscriptionInputs, - required = { - model: String, - #[pyo3(from_py_with = litellm_python_interop::from_py)] - audio: serde_json::Value, - }, - optional = { - api_key: Option, - api_base: Option, - custom_llm_provider: Option, - #[pyo3(from_py_with = litellm_python_interop::from_py)] - extra_headers: Option, - #[pyo3(from_py_with = litellm_python_interop::from_py)] - optional_params: Option, - timeout_seconds: Option, - }, - prepare = prepare_transcription, - errors = audio_transcription_error_to_pyerr, -} diff --git a/litellm-rust/crates/python-bridge/src/routes/chat_completions.rs b/litellm-rust/crates/python-bridge/src/routes/chat_completions.rs new file mode 100644 index 00000000000..67036c307e2 --- /dev/null +++ b/litellm-rust/crates/python-bridge/src/routes/chat_completions.rs @@ -0,0 +1,165 @@ +use litellm_core::chat_completions::Error; +use litellm_core::chat_completions::types::{ChatCompletionsRequest, ChatCompletionsResponse}; +use litellm_core::chat_completions::{ + chat_completions as run_chat_completions, chat_completions_decline_reason, +}; +use litellm_host_python::{from_py_argument, run_async, run_sync}; +use pyo3::prelude::*; +use serde_json::{Map, Value}; + +use crate::errors::chat_completions_error_to_pyerr; +use crate::marshal::{ + RouteOptions, extra_headers_argument, messages_argument, optional_params_argument, + optional_timeout, +}; + +async fn execute( + messages: Vec, + optional_params: Map, + options: RouteOptions, +) -> Result { + let RouteOptions { + model, + api_key, + api_base, + custom_llm_provider, + extra_headers, + timeout, + } = options; + run_chat_completions(ChatCompletionsRequest { + model: &model, + messages: Value::Array(messages), + optional_params, + api_key: api_key.as_deref(), + api_base: api_base.as_deref(), + custom_llm_provider: custom_llm_provider.as_deref(), + extra_headers, + timeout, + }) + .await +} + +#[pyfunction] +#[pyo3(signature = (model, messages, optional_params=None, custom_llm_provider=None))] +pub(crate) fn chat_completions_decline( + model: String, + #[pyo3(from_py_with = from_py_argument)] messages: Value, + #[pyo3(from_py_with = optional_params_argument)] optional_params: Option>, + custom_llm_provider: Option, +) -> Option { + chat_completions_decline_reason( + &model, + custom_llm_provider.as_deref(), + messages, + &optional_params.unwrap_or_default(), + ) + .map(str::to_string) +} + +#[pyfunction] +#[pyo3(signature = (model, messages, optional_params=None, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, timeout_seconds=None))] +#[expect( + clippy::too_many_arguments, + reason = "one parameter per Python keyword" +)] +pub(crate) fn chat_completions( + py: Python<'_>, + model: String, + #[pyo3(from_py_with = messages_argument)] messages: Vec, + #[pyo3(from_py_with = optional_params_argument)] optional_params: Option>, + api_key: Option, + api_base: Option, + custom_llm_provider: Option, + #[pyo3(from_py_with = extra_headers_argument)] extra_headers: Option>, + timeout_seconds: Option, +) -> PyResult> { + let options = RouteOptions { + model, + api_key, + api_base, + custom_llm_provider, + extra_headers, + timeout: optional_timeout(timeout_seconds), + }; + run_sync( + py, + execute(messages, optional_params.unwrap_or_default(), options), + chat_completions_error_to_pyerr, + ) +} + +#[pyfunction] +#[pyo3(signature = (model, messages, optional_params=None, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, timeout_seconds=None))] +#[expect( + clippy::too_many_arguments, + reason = "one parameter per Python keyword" +)] +pub(crate) fn achat_completions<'py>( + py: Python<'py>, + model: String, + #[pyo3(from_py_with = messages_argument)] messages: Vec, + #[pyo3(from_py_with = optional_params_argument)] optional_params: Option>, + api_key: Option, + api_base: Option, + custom_llm_provider: Option, + #[pyo3(from_py_with = extra_headers_argument)] extra_headers: Option>, + timeout_seconds: Option, +) -> PyResult> { + let options = RouteOptions { + model, + api_key, + api_base, + custom_llm_provider, + extra_headers, + timeout: optional_timeout(timeout_seconds), + }; + run_async( + py, + execute(messages, optional_params.unwrap_or_default(), options), + chat_completions_error_to_pyerr, + ) +} + +#[cfg(test)] +mod tests { + use pyo3::prelude::*; + use pyo3::types::PyList; + + #[test] + fn chat_completions_decline_keeps_existing_reasons() { + Python::initialize(); + Python::attach(|py| { + let decline = crate::native_module(py) + .getattr("chat_completions_decline") + .expect("decline helper should be registered"); + let empty = PyList::empty(py); + let unreadable = py + .eval(c"'nope'", None, None) + .expect("string messages should convert"); + + let unknown: Option = decline + .call1(("unknown-model", &empty)) + .and_then(|value| value.extract()) + .expect("unknown providers should decline"); + assert_eq!( + unknown.as_deref(), + Some("provider is not on the rust chat completions path") + ); + + let empty_reason: Option = decline + .call1(("anthropic/claude-sonnet-4-5", &empty)) + .and_then(|value| value.extract()) + .expect("empty lists should decline"); + assert_eq!(empty_reason.as_deref(), Some("empty message list")); + + let unreadable_reason: Option = decline + .call1(("anthropic/claude-sonnet-4-5", unreadable)) + .and_then(|value| value.extract()) + .expect("non-list messages should decline"); + assert_eq!( + unreadable_reason.as_deref(), + Some("unreadable message list") + ); + }); + } +} diff --git a/litellm-rust/crates/python-bridge/src/routes/chat_completions/mod.rs b/litellm-rust/crates/python-bridge/src/routes/chat_completions/mod.rs deleted file mode 100644 index 68b701802a9..00000000000 --- a/litellm-rust/crates/python-bridge/src/routes/chat_completions/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -mod value; - -use pyo3::prelude::*; - -pub(super) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { - value::register(module) -} diff --git a/litellm-rust/crates/python-bridge/src/routes/chat_completions/value.rs b/litellm-rust/crates/python-bridge/src/routes/chat_completions/value.rs deleted file mode 100644 index 09f2ada51a5..00000000000 --- a/litellm-rust/crates/python-bridge/src/routes/chat_completions/value.rs +++ /dev/null @@ -1,91 +0,0 @@ -use litellm_core::chat_completions::Error; -use std::future::Future; - -use litellm_core::chat_completions::types::{ChatCompletionsRequest, ChatCompletionsResponse}; -use litellm_core::chat_completions::{ - chat_completions as run_chat_completions, chat_completions_decline_reason, -}; -use pyo3::prelude::*; -use serde_json::Value; - -use crate::errors::chat_completions_error_to_pyerr; -use crate::marshal::{RouteOptions, RouteOptionsInputs, object_or_empty, required_array}; - -fn prepare_chat_completions( - inputs: ChatCompletionsInputs, -) -> PyResult> + Send + 'static> { - let messages = required_array("messages", inputs.messages)?; - let optional_params = object_or_empty("optional_params", inputs.optional_params)?; - let options = RouteOptions::from_python(RouteOptionsInputs { - model: inputs.model, - api_key: inputs.api_key, - api_base: inputs.api_base, - custom_llm_provider: inputs.custom_llm_provider, - extra_headers: inputs.extra_headers, - timeout_seconds: inputs.timeout_seconds, - })?; - - Ok(async move { - let RouteOptions { - model, - api_key, - api_base, - custom_llm_provider, - extra_headers, - timeout, - } = options; - run_chat_completions(ChatCompletionsRequest { - model: &model, - messages: Value::Array(messages), - optional_params, - api_key: api_key.as_deref(), - api_base: api_base.as_deref(), - custom_llm_provider: custom_llm_provider.as_deref(), - extra_headers, - timeout, - }) - .await - }) -} - -#[pyfunction] -#[pyo3(signature = (model, messages, optional_params=None, custom_llm_provider=None))] -fn chat_completions_decline( - model: String, - #[pyo3(from_py_with = litellm_python_interop::from_py)] messages: Value, - #[pyo3(from_py_with = litellm_python_interop::from_py)] optional_params: Option, - custom_llm_provider: Option, -) -> PyResult> { - let optional_params = object_or_empty("optional_params", optional_params)?; - Ok(chat_completions_decline_reason( - &model, - custom_llm_provider.as_deref(), - messages, - &optional_params, - ) - .map(str::to_string)) -} - -bridge_route! { - sync = chat_completions, - asynchronous = achat_completions, - inputs = ChatCompletionsInputs, - required = { - model: String, - #[pyo3(from_py_with = litellm_python_interop::from_py)] - messages: serde_json::Value, - }, - optional = { - #[pyo3(from_py_with = litellm_python_interop::from_py)] - optional_params: Option, - api_key: Option, - api_base: Option, - custom_llm_provider: Option, - #[pyo3(from_py_with = litellm_python_interop::from_py)] - extra_headers: Option, - timeout_seconds: Option, - }, - prepare = prepare_chat_completions, - errors = chat_completions_error_to_pyerr, - extra = [chat_completions_decline], -} diff --git a/litellm-rust/crates/python-bridge/src/routes/definition.rs b/litellm-rust/crates/python-bridge/src/routes/definition.rs deleted file mode 100644 index f846c7ea1f9..00000000000 --- a/litellm-rust/crates/python-bridge/src/routes/definition.rs +++ /dev/null @@ -1,492 +0,0 @@ -use pyo3::exceptions::PyRuntimeError; -use pyo3::prelude::*; -use pyo3::types::PyCFunction; - -macro_rules! bridge_route { - ( - sync = $sync_name:ident, - asynchronous = $async_name:ident, - inputs = $inputs:ident, - required = { $($(#[$required_attr:meta])* $required_name:ident: $required_type:ty),+ $(,)? }, - optional = { $($(#[$optional_attr:meta])* $optional_name:ident: $optional_type:ty),* $(,)? }, - prepare = $prepare:path, - errors = $map_error:path - $(, extra = [$($extra:ident),* $(,)?])? - $(,)? - ) => { - struct $inputs { - $($required_name: $required_type,)* - $($optional_name: $optional_type),* - } - - #[pyfunction] - #[pyo3(signature = ($($required_name),*, $($optional_name=None),*))] - #[allow(clippy::too_many_arguments)] - fn $sync_name( - py: pyo3::Python<'_>, - $($(#[$required_attr])* $required_name: $required_type,)* - $($(#[$optional_attr])* $optional_name: $optional_type,)* - ) -> pyo3::PyResult> { - let future = $prepare($inputs { - $($required_name,)* - $($optional_name),* - })?; - $crate::execution::run_sync(py, future, $map_error) - } - - #[pyfunction] - #[pyo3(signature = ($($required_name),*, $($optional_name=None),*))] - #[allow(clippy::too_many_arguments)] - fn $async_name( - py: pyo3::Python<'_>, - $($(#[$required_attr])* $required_name: $required_type,)* - $($(#[$optional_attr])* $optional_name: $optional_type,)* - ) -> pyo3::PyResult> { - let future = $prepare($inputs { - $($required_name,)* - $($optional_name),* - })?; - $crate::execution::run_async(py, future, $map_error) - } - - pub(super) fn register( - module: &pyo3::Bound<'_, pyo3::types::PyModule>, - ) -> pyo3::PyResult<()> { - $($($crate::routes::definition::add_function(module, pyo3::wrap_pyfunction!($extra, module)?)?;)*)? - $crate::routes::definition::add_function(module, pyo3::wrap_pyfunction!($sync_name, module)?)?; - $crate::routes::definition::add_function(module, pyo3::wrap_pyfunction!($async_name, module)?)?; - Ok(()) - } - - }; -} - -pub(super) fn add_function( - module: &Bound<'_, PyModule>, - function: Bound<'_, PyCFunction>, -) -> PyResult<()> { - let name: String = function.getattr("__name__")?.extract()?; - if module.hasattr(&name)? { - return Err(PyRuntimeError::new_err(format!( - "duplicate native route: {name}" - ))); - } - module.add_function(function) -} - -#[cfg(test)] -mod tests { - use std::ffi::CString; - use std::sync::atomic::{AtomicBool, Ordering}; - - use litellm_core::messages::Error; - use pyo3::exceptions::PyLookupError; - use pyo3::types::{PyDict, PyList}; - - use super::*; - - mod synthetic { - use std::future::{Future, pending}; - - use super::*; - - static FUTURE_DROPPED: AtomicBool = AtomicBool::new(false); - - struct DropGuard; - - impl Drop for DropGuard { - fn drop(&mut self) { - FUTURE_DROPPED.store(true, Ordering::SeqCst); - } - } - - #[pyfunction] - fn future_dropped() -> bool { - FUTURE_DROPPED.load(Ordering::SeqCst) - } - - bridge_route! { - sync = echo, - asynchronous = aecho, - inputs = EchoInputs, - required = { value: String }, - optional = {}, - prepare = prepare_echo, - errors = map_error, - extra = [future_dropped], - } - - fn prepare_echo( - inputs: EchoInputs, - ) -> PyResult> + Send + 'static> { - FUTURE_DROPPED.store(false, Ordering::SeqCst); - let drop_guard = (inputs.value == "pending").then_some(DropGuard); - Ok(execute_echo(inputs, drop_guard)) - } - - async fn execute_echo( - inputs: EchoInputs, - drop_guard: Option, - ) -> Result { - let _drop_guard = drop_guard; - tokio::task::yield_now().await; - match inputs.value.as_str() { - "error" => Err(Error::InvalidRequest("synthetic error".to_string())), - "map_panic" => Err(Error::InvalidRequest("panic in mapper".to_string())), - "panic" => panic!("synthetic panic"), - "pending" => { - pending::<()>().await; - unreachable!() - } - _ => Ok(inputs.value), - } - } - - fn map_error(error: Error) -> PyErr { - if matches!(&error, Error::InvalidRequest(message) if message == "panic in mapper") { - panic!("synthetic mapper panic") - } - PyLookupError::new_err(error.to_string()) - } - } - - #[test] - fn sync_and_async_route_signatures_match_the_python_contract() { - Python::initialize(); - Python::attach(|py| { - let module = PyModule::new(py, "routes").expect("module should be created"); - crate::routes::register(&module).expect("routes should register"); - let routes = [ - ( - "transcription", - "atranscription", - "(model, audio, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, optional_params=None, timeout_seconds=None)", - ), - ( - "messages", - "amessages", - "(model, body, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, timeout_seconds=None)", - ), - ( - "chat_completions", - "achat_completions", - "(model, messages, optional_params=None, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, timeout_seconds=None)", - ), - ]; - - for (sync_name, async_name, expected) in routes { - let sync_signature: String = module - .getattr(sync_name) - .and_then(|function| function.getattr("__text_signature__")) - .and_then(|signature| signature.extract()) - .expect("sync signature should be available"); - let async_signature: String = module - .getattr(async_name) - .and_then(|function| function.getattr("__text_signature__")) - .and_then(|signature| signature.extract()) - .expect("async signature should be available"); - - assert_eq!(sync_signature, expected); - assert_eq!(async_signature, expected); - } - }); - } - - #[test] - fn sync_and_async_routes_apply_the_same_input_validation() { - Python::initialize(); - Python::attach(|py| { - let module = PyModule::new(py, "routes").expect("module should be created"); - crate::routes::register(&module).expect("routes should register"); - - let invalid_messages = PyDict::new(py); - let sync_chat_error = module - .getattr("chat_completions") - .and_then(|function| function.call1(("model", &invalid_messages))) - .expect_err("sync chat should reject a non-list messages value"); - let async_chat_error = module - .getattr("achat_completions") - .and_then(|function| function.call1(("model", &invalid_messages))) - .expect_err("async chat should reject a non-list messages value"); - - assert_eq!( - sync_chat_error.to_string(), - "ValueError: messages must be a list" - ); - assert_eq!(async_chat_error.to_string(), sync_chat_error.to_string()); - - let invalid_body = PyList::empty(py); - let sync_messages_error = module - .getattr("messages") - .and_then(|function| function.call1(("model", &invalid_body))) - .expect_err("sync Messages should reject a non-dict body"); - let async_messages_error = module - .getattr("amessages") - .and_then(|function| function.call1(("model", &invalid_body))) - .expect_err("async Messages should reject a non-dict body"); - - assert_eq!( - sync_messages_error.to_string(), - "ValueError: body must be a dict" - ); - assert_eq!( - async_messages_error.to_string(), - sync_messages_error.to_string() - ); - - let invalid_headers = PyList::empty(py); - let kwargs = PyDict::new(py); - kwargs - .set_item("extra_headers", &invalid_headers) - .expect("kwargs should accept extra_headers"); - let audio = PyDict::new(py); - - let sync_error = module - .getattr("transcription") - .and_then(|function| function.call(("model", &audio), Some(&kwargs))) - .expect_err("sync route should reject non-dict extra_headers"); - let async_error = module - .getattr("atranscription") - .and_then(|function| function.call(("model", &audio), Some(&kwargs))) - .expect_err("async route should reject non-dict extra_headers"); - - assert_eq!( - sync_error.to_string(), - "ValueError: extra_headers must be a dict" - ); - assert_eq!(async_error.to_string(), sync_error.to_string()); - }); - } - - #[test] - fn route_input_validation_preserves_left_to_right_order() { - Python::initialize(); - Python::attach(|py| { - let module = PyModule::new(py, "routes").expect("module should be created"); - crate::routes::register(&module).expect("routes should register"); - let invalid = PyList::empty(py); - - let chat_kwargs = PyDict::new(py); - chat_kwargs - .set_item("optional_params", &invalid) - .expect("kwargs should accept optional_params"); - chat_kwargs - .set_item("extra_headers", &invalid) - .expect("kwargs should accept extra_headers"); - let invalid_messages = PyDict::new(py); - let error = module - .getattr("chat_completions") - .and_then(|function| { - function.call(("model", &invalid_messages), Some(&chat_kwargs)) - }) - .expect_err("messages should be validated first"); - assert_eq!(error.to_string(), "ValueError: messages must be a list"); - - let valid_messages = PyList::empty(py); - let error = module - .getattr("chat_completions") - .and_then(|function| function.call(("model", &valid_messages), Some(&chat_kwargs))) - .expect_err("optional_params should be validated before headers"); - assert_eq!( - error.to_string(), - "ValueError: optional_params must be a dict" - ); - - let headers_kwargs = PyDict::new(py); - headers_kwargs - .set_item("extra_headers", &invalid) - .expect("kwargs should accept extra_headers"); - let invalid_body = PyList::empty(py); - let error = module - .getattr("messages") - .and_then(|function| function.call(("model", &invalid_body), Some(&headers_kwargs))) - .expect_err("body should be validated before headers"); - assert_eq!(error.to_string(), "ValueError: body must be a dict"); - - let invalid_payload = - PyModule::new(py, "invalid_payload").expect("invalid payload should be created"); - let error = module - .getattr("transcription") - .and_then(|function| { - function.call(("model", &invalid_payload), Some(&headers_kwargs)) - }) - .expect_err("payload should be validated before headers"); - assert!(!error.to_string().contains("extra_headers")); - }); - } - - #[test] - fn missing_and_explicit_none_optional_params_share_the_next_error() { - Python::initialize(); - Python::attach(|py| { - let module = PyModule::new(py, "routes").expect("module should be created"); - crate::routes::register(&module).expect("routes should register"); - let messages = PyList::empty(py); - let headers = PyList::empty(py); - let omitted = PyDict::new(py); - omitted - .set_item("extra_headers", &headers) - .expect("kwargs should accept extra_headers"); - let explicit = PyDict::new(py); - explicit - .set_item("optional_params", py.None()) - .expect("kwargs should accept optional_params"); - explicit - .set_item("extra_headers", &headers) - .expect("kwargs should accept extra_headers"); - - let omitted_error = module - .getattr("chat_completions") - .and_then(|function| function.call(("model", &messages), Some(&omitted))) - .expect_err("omitted optional_params should reach header validation"); - let explicit_error = module - .getattr("chat_completions") - .and_then(|function| function.call(("model", &messages), Some(&explicit))) - .expect_err("None optional_params should reach header validation"); - assert_eq!( - omitted_error.to_string(), - "ValueError: extra_headers must be a dict" - ); - assert_eq!(explicit_error.to_string(), omitted_error.to_string()); - }); - } - - #[test] - fn chat_completions_decline_keeps_existing_reasons() { - Python::initialize(); - Python::attach(|py| { - let module = PyModule::new(py, "routes").expect("module should be created"); - crate::routes::register(&module).expect("routes should register"); - let decline = module - .getattr("chat_completions_decline") - .expect("decline helper should be registered"); - let empty = PyList::empty(py); - let unreadable = py - .eval(c"'nope'", None, None) - .expect("string messages should convert"); - - let unknown: Option = decline - .call1(("unknown-model", &empty)) - .and_then(|value| value.extract()) - .expect("unknown providers should decline"); - assert_eq!( - unknown.as_deref(), - Some("provider is not on the rust chat completions path") - ); - - let empty_reason: Option = decline - .call1(("anthropic/claude-sonnet-4-5", &empty)) - .and_then(|value| value.extract()) - .expect("empty lists should decline"); - assert_eq!(empty_reason.as_deref(), Some("empty message list")); - - let unreadable_reason: Option = decline - .call1(("anthropic/claude-sonnet-4-5", unreadable)) - .and_then(|value| value.extract()) - .expect("non-list messages should decline"); - assert_eq!( - unreadable_reason.as_deref(), - Some("unreadable message list") - ); - }); - } - - #[test] - fn generated_routes_execute_sync_and_async_contracts() { - Python::initialize(); - Python::attach(|py| { - let module = PyModule::new(py, "synthetic").expect("module should be created"); - synthetic::register(&module).expect("routes should register"); - - let sync_value: String = module - .getattr("echo") - .and_then(|function| function.call1(("sync",))) - .and_then(|value| value.extract()) - .expect("sync route should return its value"); - assert_eq!(sync_value, "sync"); - - let sync_error = module - .getattr("echo") - .and_then(|function| function.call1(("error",))) - .expect_err("sync route should map its error"); - assert!(sync_error.is_instance_of::(py)); - assert_eq!( - sync_error.to_string(), - "LookupError: invalid request: synthetic error" - ); - - let locals = PyDict::new(py); - locals - .set_item("routes", &module) - .expect("module should enter Python locals"); - let code = CString::new( - r#" -import asyncio - -async def exercise(): - assert await routes.aecho("async") == "async" - - try: - await routes.aecho("error") - except LookupError as error: - assert str(error) == "invalid request: synthetic error" - else: - raise AssertionError("mapped error was not raised") - - try: - await routes.aecho("panic") - except BaseException as error: - assert type(error).__name__ == "PanicException" - assert str(error) == "synthetic panic" - else: - raise AssertionError("panic was not raised") - - try: - await routes.aecho("map_panic") - except BaseException as error: - assert type(error).__name__ == "PanicException" - assert str(error) == "synthetic mapper panic" - else: - raise AssertionError("mapper panic was not raised") - - task = asyncio.ensure_future(routes.aecho("pending")) - await asyncio.sleep(0) - task.cancel() - try: - await task - except asyncio.CancelledError: - pass - else: - raise AssertionError("cancelled route completed") - - for _ in range(100): - if routes.future_dropped(): - break - await asyncio.sleep(0.001) - assert routes.future_dropped() - -asyncio.run(exercise()) -"#, - ) - .expect("Python source should not contain null bytes"); - py.run(&code, Some(&locals), Some(&locals)) - .expect("async route contract should hold"); - }); - } - - #[test] - fn route_registration_rejects_duplicate_python_names() { - Python::initialize(); - Python::attach(|py| { - let module = PyModule::new(py, "synthetic").expect("module should be created"); - synthetic::register(&module).expect("first registration should succeed"); - let error = synthetic::register(&module) - .expect_err("duplicate registration should be rejected"); - - assert_eq!( - error.to_string(), - "RuntimeError: duplicate native route: future_dropped" - ); - }); - } -} diff --git a/litellm-rust/crates/python-bridge/src/routes/messages.rs b/litellm-rust/crates/python-bridge/src/routes/messages.rs new file mode 100644 index 00000000000..371e8c27171 --- /dev/null +++ b/litellm-rust/crates/python-bridge/src/routes/messages.rs @@ -0,0 +1,87 @@ +use litellm_core::messages::Error; +use litellm_core::messages::messages as run_messages; +use litellm_core::messages::types::{AnthropicMessagesResponse, MessagesRequest}; +use litellm_host_python::{run_async, run_sync}; +use pyo3::prelude::*; +use serde_json::{Map, Value}; + +use crate::errors::messages_error_to_pyerr; +use crate::marshal::{RouteOptions, body_argument, extra_headers_argument, optional_timeout}; + +async fn execute( + body: Map, + options: RouteOptions, +) -> Result { + let RouteOptions { + model, + api_key, + api_base, + custom_llm_provider, + extra_headers, + timeout, + } = options; + run_messages(MessagesRequest { + model: &model, + body: Value::Object(body), + api_key: api_key.as_deref(), + api_base: api_base.as_deref(), + custom_llm_provider: custom_llm_provider.as_deref(), + extra_headers, + timeout, + }) + .await +} + +#[pyfunction] +#[pyo3(signature = (model, body, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, timeout_seconds=None))] +#[expect( + clippy::too_many_arguments, + reason = "one parameter per Python keyword" +)] +pub(crate) fn messages( + py: Python<'_>, + model: String, + #[pyo3(from_py_with = body_argument)] body: Map, + api_key: Option, + api_base: Option, + custom_llm_provider: Option, + #[pyo3(from_py_with = extra_headers_argument)] extra_headers: Option>, + timeout_seconds: Option, +) -> PyResult> { + let options = RouteOptions { + model, + api_key, + api_base, + custom_llm_provider, + extra_headers, + timeout: optional_timeout(timeout_seconds), + }; + run_sync(py, execute(body, options), messages_error_to_pyerr) +} + +#[pyfunction] +#[pyo3(signature = (model, body, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, timeout_seconds=None))] +#[expect( + clippy::too_many_arguments, + reason = "one parameter per Python keyword" +)] +pub(crate) fn amessages<'py>( + py: Python<'py>, + model: String, + #[pyo3(from_py_with = body_argument)] body: Map, + api_key: Option, + api_base: Option, + custom_llm_provider: Option, + #[pyo3(from_py_with = extra_headers_argument)] extra_headers: Option>, + timeout_seconds: Option, +) -> PyResult> { + let options = RouteOptions { + model, + api_key, + api_base, + custom_llm_provider, + extra_headers, + timeout: optional_timeout(timeout_seconds), + }; + run_async(py, execute(body, options), messages_error_to_pyerr) +} diff --git a/litellm-rust/crates/python-bridge/src/routes/messages/mod.rs b/litellm-rust/crates/python-bridge/src/routes/messages/mod.rs deleted file mode 100644 index 68b701802a9..00000000000 --- a/litellm-rust/crates/python-bridge/src/routes/messages/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -mod value; - -use pyo3::prelude::*; - -pub(super) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { - value::register(module) -} diff --git a/litellm-rust/crates/python-bridge/src/routes/messages/value.rs b/litellm-rust/crates/python-bridge/src/routes/messages/value.rs deleted file mode 100644 index f5eb80d765c..00000000000 --- a/litellm-rust/crates/python-bridge/src/routes/messages/value.rs +++ /dev/null @@ -1,65 +0,0 @@ -use litellm_core::messages::Error; -use litellm_core::messages::messages as run_messages; -use litellm_core::messages::types::{AnthropicMessagesResponse, MessagesRequest}; -use pyo3::prelude::*; -use serde_json::Value; -use std::future::Future; - -use crate::errors::messages_error_to_pyerr; -use crate::marshal::{RouteOptions, RouteOptionsInputs, required_object}; - -fn prepare_messages( - inputs: MessagesInputs, -) -> PyResult> + Send + 'static> { - let body = required_object("body", inputs.body)?; - let options = RouteOptions::from_python(RouteOptionsInputs { - model: inputs.model, - api_key: inputs.api_key, - api_base: inputs.api_base, - custom_llm_provider: inputs.custom_llm_provider, - extra_headers: inputs.extra_headers, - timeout_seconds: inputs.timeout_seconds, - })?; - - Ok(async move { - let RouteOptions { - model, - api_key, - api_base, - custom_llm_provider, - extra_headers, - timeout, - } = options; - run_messages(MessagesRequest { - model: &model, - body: Value::Object(body), - api_key: api_key.as_deref(), - api_base: api_base.as_deref(), - custom_llm_provider: custom_llm_provider.as_deref(), - extra_headers, - timeout, - }) - .await - }) -} - -bridge_route! { - sync = messages, - asynchronous = amessages, - inputs = MessagesInputs, - required = { - model: String, - #[pyo3(from_py_with = litellm_python_interop::from_py)] - body: serde_json::Value, - }, - optional = { - api_key: Option, - api_base: Option, - custom_llm_provider: Option, - #[pyo3(from_py_with = litellm_python_interop::from_py)] - extra_headers: Option, - timeout_seconds: Option, - }, - prepare = prepare_messages, - errors = messages_error_to_pyerr, -} diff --git a/litellm-rust/crates/python-bridge/src/routes/mod.rs b/litellm-rust/crates/python-bridge/src/routes/mod.rs index 4e2530a94f8..b6ada947597 100644 --- a/litellm-rust/crates/python-bridge/src/routes/mod.rs +++ b/litellm-rust/crates/python-bridge/src/routes/mod.rs @@ -1,17 +1,247 @@ -use pyo3::prelude::*; +pub(crate) mod audio_transcription; +pub(crate) mod chat_completions; +pub(crate) mod messages; +pub(crate) mod ocr; +pub(crate) mod responses; -#[macro_use] -mod definition; +#[cfg(test)] +mod tests { + use pyo3::prelude::*; + use pyo3::types::{PyDict, PyList}; -mod audio_transcription; -mod chat_completions; -mod messages; -mod ocr; + #[test] + fn sync_and_async_route_signatures_match_the_python_contract() { + Python::initialize(); + Python::attach(|py| { + let module = crate::native_module(py); + let routes = [ + ( + "transcription", + "atranscription", + "(model, audio, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, optional_params=None, timeout_seconds=None)", + ), + ( + "messages", + "amessages", + "(model, body, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, timeout_seconds=None)", + ), + ( + "chat_completions", + "achat_completions", + "(model, messages, optional_params=None, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, timeout_seconds=None)", + ), + ]; -pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { - ocr::register(module)?; - audio_transcription::register(module)?; - messages::register(module)?; - chat_completions::register(module)?; - Ok(()) + for (sync_name, async_name, expected) in routes { + let sync_signature: String = module + .getattr(sync_name) + .and_then(|function| function.getattr("__text_signature__")) + .and_then(|signature| signature.extract()) + .expect("sync signature should be available"); + let async_signature: String = module + .getattr(async_name) + .and_then(|function| function.getattr("__text_signature__")) + .and_then(|signature| signature.extract()) + .expect("async signature should be available"); + + assert_eq!(sync_signature, expected); + assert_eq!(async_signature, expected); + } + }); + } + + #[test] + fn route_arguments_that_fail_to_convert_raise_value_error() { + Python::initialize(); + Python::attach(|py| { + let module = crate::native_module(py); + + let locals = PyDict::new(py); + py.run( + pyo3::ffi::c_str!( + r#" +class Broken: + def __index__(self): + raise LookupError('conversion failed') +value = Broken() +"# + ), + Some(&locals), + Some(&locals), + ) + .expect("helper class should define"); + let broken = locals + .get_item("value") + .expect("locals should be readable") + .expect("helper value should exist"); + + for name in ["chat_completions", "achat_completions"] { + let error = module + .getattr(name) + .and_then(|function| function.call1(("model", &broken))) + .expect_err("route should reject a value it cannot convert"); + + assert!( + error.is_instance_of::(py), + "{name} surfaced {error} instead of ValueError" + ); + } + }); + } + + #[test] + fn sync_and_async_routes_apply_the_same_input_validation() { + Python::initialize(); + Python::attach(|py| { + let module = crate::native_module(py); + + let invalid_messages = PyDict::new(py); + let sync_chat_error = module + .getattr("chat_completions") + .and_then(|function| function.call1(("model", &invalid_messages))) + .expect_err("sync chat should reject a non-list messages value"); + let async_chat_error = module + .getattr("achat_completions") + .and_then(|function| function.call1(("model", &invalid_messages))) + .expect_err("async chat should reject a non-list messages value"); + + assert_eq!( + sync_chat_error.to_string(), + "ValueError: messages must be a list" + ); + assert_eq!(async_chat_error.to_string(), sync_chat_error.to_string()); + + let invalid_body = PyList::empty(py); + let sync_messages_error = module + .getattr("messages") + .and_then(|function| function.call1(("model", &invalid_body))) + .expect_err("sync Messages should reject a non-dict body"); + let async_messages_error = module + .getattr("amessages") + .and_then(|function| function.call1(("model", &invalid_body))) + .expect_err("async Messages should reject a non-dict body"); + + assert_eq!( + sync_messages_error.to_string(), + "ValueError: body must be a dict" + ); + assert_eq!( + async_messages_error.to_string(), + sync_messages_error.to_string() + ); + + let invalid_headers = PyList::empty(py); + let kwargs = PyDict::new(py); + kwargs + .set_item("extra_headers", &invalid_headers) + .expect("kwargs should accept extra_headers"); + let audio = PyDict::new(py); + + let sync_error = module + .getattr("transcription") + .and_then(|function| function.call(("model", &audio), Some(&kwargs))) + .expect_err("sync route should reject non-dict extra_headers"); + let async_error = module + .getattr("atranscription") + .and_then(|function| function.call(("model", &audio), Some(&kwargs))) + .expect_err("async route should reject non-dict extra_headers"); + + assert_eq!( + sync_error.to_string(), + "ValueError: extra_headers must be a dict" + ); + assert_eq!(async_error.to_string(), sync_error.to_string()); + }); + } + + #[test] + fn route_input_validation_preserves_left_to_right_order() { + Python::initialize(); + Python::attach(|py| { + let module = crate::native_module(py); + let invalid = PyList::empty(py); + + let chat_kwargs = PyDict::new(py); + chat_kwargs + .set_item("optional_params", &invalid) + .expect("kwargs should accept optional_params"); + chat_kwargs + .set_item("extra_headers", &invalid) + .expect("kwargs should accept extra_headers"); + let invalid_messages = PyDict::new(py); + let error = module + .getattr("chat_completions") + .and_then(|function| { + function.call(("model", &invalid_messages), Some(&chat_kwargs)) + }) + .expect_err("messages should be validated first"); + assert_eq!(error.to_string(), "ValueError: messages must be a list"); + + let valid_messages = PyList::empty(py); + let error = module + .getattr("chat_completions") + .and_then(|function| function.call(("model", &valid_messages), Some(&chat_kwargs))) + .expect_err("optional_params should be validated before headers"); + assert_eq!( + error.to_string(), + "ValueError: optional_params must be a dict" + ); + + let headers_kwargs = PyDict::new(py); + headers_kwargs + .set_item("extra_headers", &invalid) + .expect("kwargs should accept extra_headers"); + let invalid_body = PyList::empty(py); + let error = module + .getattr("messages") + .and_then(|function| function.call(("model", &invalid_body), Some(&headers_kwargs))) + .expect_err("body should be validated before headers"); + assert_eq!(error.to_string(), "ValueError: body must be a dict"); + + let invalid_payload = + PyModule::new(py, "invalid_payload").expect("invalid payload should be created"); + let error = module + .getattr("transcription") + .and_then(|function| { + function.call(("model", &invalid_payload), Some(&headers_kwargs)) + }) + .expect_err("payload should be validated before headers"); + assert!(!error.to_string().contains("extra_headers")); + }); + } + + #[test] + fn missing_and_explicit_none_optional_params_share_the_next_error() { + Python::initialize(); + Python::attach(|py| { + let module = crate::native_module(py); + let messages = PyList::empty(py); + let headers = PyList::empty(py); + let omitted = PyDict::new(py); + omitted + .set_item("extra_headers", &headers) + .expect("kwargs should accept extra_headers"); + let explicit = PyDict::new(py); + explicit + .set_item("optional_params", py.None()) + .expect("kwargs should accept optional_params"); + explicit + .set_item("extra_headers", &headers) + .expect("kwargs should accept extra_headers"); + + let omitted_error = module + .getattr("chat_completions") + .and_then(|function| function.call(("model", &messages), Some(&omitted))) + .expect_err("omitted optional_params should reach header validation"); + let explicit_error = module + .getattr("chat_completions") + .and_then(|function| function.call(("model", &messages), Some(&explicit))) + .expect_err("None optional_params should reach header validation"); + assert_eq!( + omitted_error.to_string(), + "ValueError: extra_headers must be a dict" + ); + assert_eq!(explicit_error.to_string(), omitted_error.to_string()); + }); + } } diff --git a/litellm-rust/crates/python-bridge/src/routes/ocr/callbacks.rs b/litellm-rust/crates/python-bridge/src/routes/ocr/callbacks.rs deleted file mode 100644 index 302a31a759d..00000000000 --- a/litellm-rust/crates/python-bridge/src/routes/ocr/callbacks.rs +++ /dev/null @@ -1,179 +0,0 @@ -use pyo3::exceptions::PyBaseException; -use pyo3::prelude::*; -use pyo3::types::PyDict; -use serde_json::Value; - -use litellm_core::ocr::LiteLLMOcrResponse; -use litellm_core::ocr::hooks::OcrPreCallRequest; -use litellm_python_interop::to_py_preserving_errors as to_py; - -use crate::lifecycle::PythonLogger; - -pub(super) struct OcrLoggingFields { - model: String, - custom_llm_provider: String, - optional_params: Value, -} - -impl From<&OcrPreCallRequest> for OcrLoggingFields { - fn from(request: &OcrPreCallRequest) -> Self { - Self { - model: request.model.clone(), - custom_llm_provider: request.custom_llm_provider.clone(), - optional_params: request.optional_params.clone(), - } - } -} - -impl PythonLogger { - pub(super) fn update_ocr( - &self, - py: Python<'_>, - kwargs: &Py, - pre_call: &OcrLoggingFields, - secret_fields: &[&str], - url: &str, - ) -> PyResult<()> { - let update = PyDict::new(py); - update.set_item("kwargs", redact(py, kwargs.bind(py), secret_fields)?)?; - update.set_item("model", &pre_call.model)?; - update.set_item( - "optional_params", - redact( - py, - &to_py(py, &pre_call.optional_params)? - .into_bound(py) - .cast_into::()?, - secret_fields, - )?, - )?; - let params = PyDict::new(py); - params.set_item( - "litellm_call_id", - kwargs.bind(py).get_item("litellm_call_id")?, - )?; - params.set_item("api_base", url)?; - for name in ["logger_fn", "litellm_request_debug"] { - if let Some(value) = kwargs.bind(py).get_item(name)? { - params.set_item(name, value)?; - } - } - for name in custom_pricing_fields(py)? { - if let Some(value) = kwargs.bind(py).get_item(&name)? - && !value.is_none() - { - params.set_item(name, value)?; - } - } - update.set_item("litellm_params", params)?; - update.set_item("custom_llm_provider", &pre_call.custom_llm_provider)?; - self.object(py) - .call_method("update_from_kwargs", (), Some(&update))?; - Ok(()) - } - - pub(crate) fn pre_ocr( - &self, - py: Python<'_>, - api_key: &Option>, - body: &Bound<'_, PyDict>, - headers: &Bound<'_, PyDict>, - url: &str, - ) -> PyResult<()> { - let additional = PyDict::new(py); - additional.set_item("complete_input_dict", body)?; - additional.set_item("headers", headers)?; - additional.set_item("api_base", url)?; - let kwargs = PyDict::new(py); - kwargs.set_item("input", "OCR document processing")?; - kwargs.set_item("api_key", api_key)?; - kwargs.set_item("additional_args", &additional)?; - if self.callbacks_needed(py, "input")? { - self.object(py).call_method("pre_call", (), Some(&kwargs))?; - } else { - self.object(py) - .call_method("_pre_call", (), Some(&kwargs))?; - self.object(py).call_method0("record_api_call_start_time")?; - } - Ok(()) - } - - pub(crate) fn post_ocr( - &self, - py: Python<'_>, - original_response: &Value, - body: Option<&Py>, - headers: Option<&Py>, - ) -> PyResult<()> { - let additional = PyDict::new(py); - additional.set_item("complete_input_dict", body)?; - additional.set_item("headers", headers)?; - if self.callbacks_needed(py, "input")? { - let kwargs = PyDict::new(py); - kwargs.set_item("original_response", to_py(py, original_response)?)?; - kwargs.set_item("additional_args", &additional)?; - self.object(py) - .call_method("post_call", (), Some(&kwargs))?; - } else { - let response = py - .import("json")? - .call_method1("dumps", (to_py(py, original_response)?,))?; - self.object(py).call_method1( - "record_post_call", - (response, py.None(), py.None(), additional), - )?; - } - Ok(()) - } -} - -fn custom_pricing_fields(py: Python<'_>) -> PyResult> { - py.import("litellm.types.utils")? - .getattr("CustomPricingLiteLLMParams")? - .getattr("model_fields")? - .cast_into::()? - .keys() - .iter() - .map(|name| name.extract::()) - .collect() -} - -fn redact( - py: Python<'_>, - params: &Bound<'_, PyDict>, - secret_fields: &[&str], -) -> PyResult> { - let redacted = PyDict::new(py); - for (name, value) in params { - let name = name.extract::()?; - if name == "proxy_server_request" { - continue; - } - if secret_fields.contains(&name.as_str()) { - redacted.set_item(name, "****")?; - } else { - redacted.set_item(name, value)?; - } - } - Ok(redacted.unbind()) -} - -pub(super) fn response(py: Python<'_>, response: &LiteLLMOcrResponse) -> PyResult> { - py.import("litellm.rust_bridge.ocr.callbacks")? - .getattr("response")? - .call1((to_py(py, response)?,)) - .map(Bound::unbind) -} - -pub(super) fn map_failure( - py: Python<'_>, - error: &Py, - request: &Bound<'_, PyAny>, - provider: &str, -) -> PyResult> { - Ok(py - .import("litellm.rust_bridge.ocr.callbacks")? - .getattr("map_failure")? - .call1((error, request, provider))? - .extract()?) -} diff --git a/litellm-rust/crates/python-bridge/src/routes/ocr/document.rs b/litellm-rust/crates/python-bridge/src/routes/ocr/document.rs index 33c0561184d..1a111ca2c11 100644 --- a/litellm-rust/crates/python-bridge/src/routes/ocr/document.rs +++ b/litellm-rust/crates/python-bridge/src/routes/ocr/document.rs @@ -288,6 +288,41 @@ wrong = {'file': Wrong()}", }); } + #[rstest::rstest] + #[case::read("read")] + #[case::name("name")] + fn reader_attribute_failures_keep_their_identity(#[case] attribute: &str) { + Python::initialize(); + Python::attach(|py| { + let locals = eval( + py, + c"failure = LookupError('file property failed') +class File: + def __getattribute__(self, name): + if name == attribute: + raise failure + return super().__getattribute__(name) + name = 'scan.pdf' + def read(self): + return b'abc' +document = {'file': File()}", + ); + locals.set_item("attribute", attribute).unwrap(); + let error = locals + .get_item("document") + .unwrap() + .unwrap() + .extract::() + .err() + .unwrap(); + assert!( + error + .value(py) + .is(locals.get_item("failure").unwrap().unwrap()) + ); + }); + } + #[test] fn exact_python_bytes_transfer_without_copying_and_outlive_the_input() { Python::initialize(); diff --git a/litellm-rust/crates/python-bridge/src/routes/ocr/errors.rs b/litellm-rust/crates/python-bridge/src/routes/ocr/errors.rs index 9bd29ce601f..215060b7a9b 100644 --- a/litellm-rust/crates/python-bridge/src/routes/ocr/errors.rs +++ b/litellm-rust/crates/python-bridge/src/routes/ocr/errors.rs @@ -110,4 +110,86 @@ mod tests { ); }); } + + #[test] + fn invalid_request_format_is_a_flagged_bad_request() { + Python::initialize(); + Python::attach(|py| { + let mapped = to_pyerr(Error::RequestFormat); + let value = mapped.value(py); + assert!(mapped.is_instance_of::(py)); + assert!( + value + .getattr("ocr_request_format_error") + .unwrap() + .extract::() + .unwrap() + ); + assert_eq!( + value + .getattr("status_code") + .unwrap() + .extract::() + .unwrap(), + 400 + ); + assert_eq!( + value + .getattr("message") + .unwrap() + .extract::() + .unwrap(), + Error::RequestFormat.to_string() + ); + }); + } + + fn file_read(kind: std::io::ErrorKind) -> Error { + Error::FileRead { + path: "/missing/scan.pdf".into(), + source: std::sync::Arc::new(std::io::Error::new(kind, "disk said no")), + } + } + + #[test] + fn missing_files_map_to_file_not_found_naming_the_path() { + Python::initialize(); + Python::attach(|py| { + let mapped = to_pyerr(file_read(std::io::ErrorKind::NotFound)); + assert!(mapped.is_instance_of::(py)); + assert_eq!( + mapped.value(py).to_string(), + "File not found: /missing/scan.pdf" + ); + }); + } + + #[test] + fn other_file_read_failures_map_to_os_error_with_the_io_message() { + Python::initialize(); + Python::attach(|py| { + let mapped = to_pyerr(file_read(std::io::ErrorKind::PermissionDenied)); + assert!(mapped.is_instance_of::(py)); + assert!(!mapped.is_instance_of::(py)); + assert_eq!(mapped.value(py).to_string(), "disk said no"); + }); + } + + #[rstest::rstest] + #[case::oversized(Error::TooLarge { limit: 7 })] + #[case::malformed_field(Error::ResponseField { path: "pages[0].index".into() })] + fn response_failures_are_statusless_runtime_errors(#[case] error: Error) { + Python::initialize(); + Python::attach(|py| { + let message = error.to_string(); + let mapped = to_pyerr(error); + let value = mapped.value(py); + assert!(mapped.is_instance_of::(py)); + assert!(!mapped.is_instance_of::(py)); + assert_eq!(value.to_string(), message); + for attribute in ["status_code", "ocr_request_format_error", "headers"] { + assert!(!value.hasattr(attribute).unwrap(), "{attribute}"); + } + }); + } } diff --git a/litellm-rust/crates/python-bridge/src/routes/ocr/host.rs b/litellm-rust/crates/python-bridge/src/routes/ocr/host.rs new file mode 100644 index 00000000000..a0f2714753d --- /dev/null +++ b/litellm-rust/crates/python-bridge/src/routes/ocr/host.rs @@ -0,0 +1,205 @@ +use litellm_auth::ResolvedCredential; +use litellm_core::ocr::{LiteLLMOcrResponse, Ocr, OcrOp, OcrOpResult}; +use litellm_host_python::{RouteHost, missing_state, to_py}; +use pyo3::exceptions::PyBaseException; +use pyo3::gc::{PyTraverseError, PyVisit}; +use pyo3::prelude::*; +use pyo3::types::PyDict; + +use super::errors::to_pyerr as ocr_error_to_pyerr; +use super::project::{OcrHostHandles, project_request}; + +enum OcrHostData { + Unprojected, + Projected(Box), + Released, +} + +/// The Python side of the OCR route: projects the prepared arguments, reads file-like +/// documents, acquires Azure AD tokens, and builds the public response and exception. +pub(super) struct OcrRouteHost { + request: Py, + data: OcrHostData, +} + +impl OcrRouteHost { + pub(super) fn new(request: Py) -> Self { + Self { + request, + data: OcrHostData::Unprojected, + } + } + + fn handles(&self) -> PyResult<&OcrHostHandles> { + match &self.data { + OcrHostData::Projected(handles) => Ok(handles), + _ => Err(missing_state()), + } + } + + fn read_document(&self, py: Python<'_>) -> PyResult { + self.handles()? + .reader + .as_ref() + .ok_or_else(missing_state)? + .read(py) + } + + fn acquire_azure_ad_token(&self, py: Python<'_>) -> PyResult { + self.handles()? + .azure_ad_token_provider + .as_ref() + .ok_or_else(missing_state)? + .acquire(py) + } +} + +impl RouteHost for OcrRouteHost { + type Route = Ocr; + + fn invoke( + &mut self, + py: Python<'_>, + arguments: &Bound<'_, PyDict>, + op: OcrOp, + ) -> PyResult { + match op { + OcrOp::ProjectRequest => { + let OcrHostData::Unprojected = self.data else { + return Err(missing_state()); + }; + let (request, handles) = project_request(self.request.bind(py), arguments)?; + let caller_token = handles.azure_ad_token_provider.is_some(); + self.data = OcrHostData::Projected(Box::new(handles)); + Ok(OcrOpResult::Request { + request: Box::new(request), + caller_token, + }) + } + OcrOp::ReadDocument => self.read_document(py).map(OcrOpResult::Document), + OcrOp::AcquireAzureAdToken => self + .acquire_azure_ad_token(py) + .map(OcrOpResult::AzureAdToken), + } + } + + fn complete(&mut self, py: Python<'_>, response: LiteLLMOcrResponse) -> PyResult> { + py.import("litellm.rust_bridge.ocr.route_host")? + .getattr("response")? + .call1((to_py(py, &response)?,)) + .map(Bound::unbind) + } + + fn native_error(error: litellm_core::ocr::Error) -> PyErr { + ocr_error_to_pyerr(error) + } + + fn host_error(error: &PyErr) -> litellm_core::ocr::Error { + litellm_core::ocr::Error::InvalidRequest(error.to_string()) + } + + fn map_failure(&self, py: Python<'_>, error: &PyErr) -> PyResult { + let provider = match &self.data { + OcrHostData::Projected(handles) => handles.provider, + _ => "", + }; + let mapped: Py = py + .import("litellm.rust_bridge.ocr.route_host")? + .getattr("map_failure")? + .call1((error.value(py), self.request.bind(py), provider))? + .extract()?; + Ok(PyErr::from_value(mapped.into_bound(py).into_any())) + } + + fn close(&mut self, _: Python<'_>) { + self.data = OcrHostData::Released; + } + + fn traverse(&self, visit: &PyVisit<'_>) -> Result<(), PyTraverseError> { + visit.call(&self.request)?; + if let OcrHostData::Projected(handles) = &self.data { + if let Some(reader) = &handles.reader { + reader.traverse(visit)?; + } + if let Some(provider) = &handles.azure_ad_token_provider { + provider.traverse(visit)?; + } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[rstest::rstest] + #[case::acquired(true)] + #[case::provider_raised(false)] + fn closing_releases_the_token_provider(#[case] succeeds: bool) { + Python::initialize(); + Python::attach(|py| { + let locals = PyDict::new(py); + locals.set_item("succeeds", succeeds).unwrap(); + py.run( + c" +import gc +import weakref +class Provider: + def __call__(self): + if succeeds: + return 'caller-token' + raise ValueError('unavailable') +provider = Provider() +reference = weakref.ref(provider) +kwargs = { + 'model': 'azure_ai/mistral-ocr-latest', + 'custom_llm_provider': None, + 'document': {'type': 'document_url', 'document_url': 'https://example.com/a.pdf'}, + 'api_key': None, + 'api_base': None, + 'extra_headers': None, + 'timeout': None, + 'azure_ad_token_provider': provider, +} +del provider +", + Some(&locals), + Some(&locals), + ) + .unwrap(); + let kwargs = locals + .get_item("kwargs") + .unwrap() + .unwrap() + .cast_into::() + .unwrap(); + let mut host = OcrRouteHost::new(py.None()); + let projected = host.invoke(py, &kwargs, OcrOp::ProjectRequest).unwrap(); + assert!(matches!( + projected, + OcrOpResult::Request { + caller_token: true, + .. + } + )); + locals.del_item("kwargs").unwrap(); + drop(kwargs); + assert_eq!( + host.invoke(py, &PyDict::new(py), OcrOp::AcquireAzureAdToken) + .is_ok(), + succeeds + ); + let alive = || { + py.run(c"gc.collect()", Some(&locals), Some(&locals)) + .unwrap(); + !py.eval(c"reference()", Some(&locals), Some(&locals)) + .unwrap() + .is_none() + }; + assert!(alive()); + host.close(py); + assert!(!alive()); + }); + } +} diff --git a/litellm-rust/crates/python-bridge/src/routes/ocr/lifecycle.rs b/litellm-rust/crates/python-bridge/src/routes/ocr/lifecycle.rs deleted file mode 100644 index d581c69a43e..00000000000 --- a/litellm-rust/crates/python-bridge/src/routes/ocr/lifecycle.rs +++ /dev/null @@ -1,353 +0,0 @@ -use pyo3::prelude::*; -use pyo3::types::{PyDict, PyTuple}; - -use litellm_auth::ResolvedCredential; -use litellm_core::ocr::hooks::{OcrDuringCallRequest, OcrPostCallRequest, OcrPreCallRequest}; -use litellm_core::ocr::{OcrAdmission, OcrCall, OcrClient, OcrHostOperation, OcrHostResult}; -use litellm_python_interop::{ - from_py_preserving_errors as from_py, to_py_preserving_errors as to_py, -}; - -use super::callbacks; -use super::errors::to_pyerr as ocr_error_to_pyerr; -use super::project::{ProjectedOcrFields, admitted_call, project_request}; -use crate::lifecycle::{ - OperationClass, PythonCallState, PythonRoute, missing_state, now, run_call, -}; - -struct PythonOcrHost { - state: PythonCallState, - data: OcrHostData, -} - -enum OcrHostData { - Unprojected { request: Py }, - Projected(Box), - Released, -} - -struct ProjectedOcrHost { - fields: ProjectedOcrFields, - pre_call: Option, - retained_fields: Option>, - body: Option>, - headers: Option>, -} - -impl PythonOcrHost { - fn projected(&self) -> PyResult<&ProjectedOcrHost> { - match &self.data { - OcrHostData::Projected(projected) => Ok(projected), - _ => Err(missing_state()), - } - } - - fn projected_mut(&mut self) -> PyResult<&mut ProjectedOcrHost> { - match &mut self.data { - OcrHostData::Projected(projected) => Ok(projected), - _ => Err(missing_state()), - } - } - - fn pre_call( - &mut self, - py: Python<'_>, - request: OcrPreCallRequest, - ) -> PyResult { - let kwargs = self.state.kwargs.bind(py); - let retained_fields = PyDict::new(py); - for name in request - .optional_params - .as_object() - .ok_or_else(missing_state)? - .keys() - { - if let Some(value) = kwargs.get_item(name)? { - retained_fields.set_item(name, value)?; - } - } - let projected = self.projected_mut()?; - let document = match &projected.fields.document { - Some(document) => document.clone_ref(py), - None => to_py(py, &request.document)?, - }; - retained_fields.set_item("document", &document)?; - projected.fields.document = Some(document); - projected.retained_fields = Some(retained_fields.unbind()); - projected.pre_call = Some((&request).into()); - Ok(request) - } - - fn read_document(&self, py: Python<'_>) -> PyResult { - self.projected()? - .fields - .reader - .as_ref() - .ok_or_else(missing_state)? - .read(py) - } - - fn acquire_azure_ad_token(&self, py: Python<'_>) -> PyResult { - let provider = self - .projected()? - .fields - .azure_ad_token_provider - .as_ref() - .ok_or_else(missing_state)?; - provider.acquire(py) - } - - fn python_pre_call( - &mut self, - py: Python<'_>, - mut request: OcrDuringCallRequest, - ) -> PyResult { - let projected = self.projected()?; - let pre_call = projected.pre_call.as_ref().ok_or_else(missing_state)?; - self.state.logger()?.update_ocr( - py, - &self.state.kwargs, - pre_call, - &projected.fields.secret_fields, - &request.url, - )?; - if !self.state.logger()?.callbacks_needed(py, "payload")? { - self.state - .logger()? - .object(py) - .call_method0("record_api_call_start_time")?; - return Ok(request); - } - if let Some(body) = request.body.as_object_mut() { - for name in &request.retained_fields { - body.remove(name); - } - } - let body = to_py(py, &request.body)? - .into_bound(py) - .cast_into::()?; - if let Some(retained) = &self.projected()?.retained_fields { - for name in &request.retained_fields { - if let Some(value) = retained.bind(py).get_item(name)? { - body.set_item(name, value)?; - } - } - } - let headers = PyDict::new(py); - for (name, value) in &request.headers { - headers.set_item(name, value)?; - } - let api_key = self.projected()?.fields.api_key.clone_ref(py); - let projected = self.projected_mut()?; - projected.body = Some(body.clone().unbind()); - projected.headers = Some(headers.clone().unbind()); - self.state - .logger()? - .pre_ocr(py, &Some(api_key), &body, &headers, &request.url)?; - let headers = headers - .iter() - .map(|(name, value)| Ok((name.extract::()?, value.extract::()?))) - .collect::>>()?; - request.body = from_py(&body)?; - request.headers = headers; - Ok(request) - } - - fn python_post_call( - &mut self, - py: Python<'_>, - request: OcrPostCallRequest, - ) -> PyResult { - let logger = self.state.logger()?; - if logger.callbacks_needed(py, "payload")? { - let projected = self.projected()?; - logger.post_ocr( - py, - &request.original_response, - projected.body.as_ref(), - projected.headers.as_ref(), - )?; - } - Ok(request) - } -} - -impl PythonRoute for PythonOcrHost { - type Call = OcrCall; - - fn state(&self) -> &PythonCallState { - &self.state - } - - fn state_mut(&mut self) -> &mut PythonCallState { - &mut self.state - } - - fn classify(operation: &OcrHostOperation) -> OperationClass { - operation - .phase() - .map_or(OperationClass::Route, OperationClass::Phase) - } - - fn lifecycle_result() -> OcrHostResult { - OcrHostResult::Lifecycle(Ok(())) - } - - fn map_error(error: litellm_core::ocr::Error) -> PyErr { - ocr_error_to_pyerr(error) - } - - fn host_error(message: String) -> litellm_core::ocr::Error { - litellm_core::ocr::Error::InvalidRequest(message) - } - - fn invoke(&mut self, py: Python<'_>, operation: OcrHostOperation) -> PyResult { - Ok(match operation { - OcrHostOperation::ProjectRequest => { - let OcrHostData::Unprojected { request } = &self.data else { - return Err(missing_state()); - }; - let projected = project_request(request.bind(py), self.state.kwargs.bind(py))?; - let has_token_provider = projected.fields.azure_ad_token_provider.is_some(); - let request = projected.request; - self.data = OcrHostData::Projected(Box::new(ProjectedOcrHost { - fields: projected.fields, - pre_call: None, - retained_fields: None, - body: None, - headers: None, - })); - OcrHostResult::Request(Ok((Box::new(request), has_token_provider))) - } - OcrHostOperation::ReadDocument => OcrHostResult::Document(Ok(self.read_document(py)?)), - OcrHostOperation::AcquireAzureAdToken => { - OcrHostResult::AzureAdToken(Ok(self.acquire_azure_ad_token(py)?)) - } - OcrHostOperation::PreCall(request) => { - OcrHostResult::PreCall(Ok(self.pre_call(py, request)?)) - } - OcrHostOperation::DuringCall(request) => { - OcrHostResult::DuringCall(Ok(self.python_pre_call(py, request)?)) - } - OcrHostOperation::PostCall(request) => { - OcrHostResult::PostCall(Ok(self.python_post_call(py, request)?)) - } - OcrHostOperation::ConstructResponse(response) => { - self.state.end = Some(now(py)?); - self.state.response = Some(callbacks::response(py, response.as_ref())?); - OcrHostResult::Lifecycle(Ok(())) - } - OcrHostOperation::MapFailure(error) => { - if self.state.error.is_none() { - self.state.retain_error(py, ocr_error_to_pyerr(error)); - } - if self.state.end.is_none() { - self.state.end = Some(now(py)?); - } - let error = self.state.error.as_ref().ok_or_else(missing_state)?; - let (request, provider) = match &self.data { - OcrHostData::Unprojected { request } => (request.bind(py), ""), - OcrHostData::Projected(projected) => ( - projected.fields.boundary_request.bind(py), - projected.fields.provider, - ), - OcrHostData::Released => return Err(missing_state()), - }; - let mapped = callbacks::map_failure(py, error, request, provider)?; - self.state - .retain_error(py, PyErr::from_value(mapped.into_bound(py).into_any())); - OcrHostResult::Lifecycle(Ok(())) - } - OcrHostOperation::Lifecycle(_) - | OcrHostOperation::Success { .. } - | OcrHostOperation::Failure { .. } => return Err(missing_state()), - }) - } - - fn cleanup(&mut self) { - self.data = OcrHostData::Released; - } - fn traverse(&self, visit: &pyo3::gc::PyVisit<'_>) -> Result<(), pyo3::gc::PyTraverseError> { - match &self.data { - OcrHostData::Unprojected { request } => visit.call(request), - OcrHostData::Projected(projected) => { - visit.call(&projected.fields.boundary_request)?; - visit.call(&projected.fields.document)?; - if let Some(reader) = &projected.fields.reader { - reader.traverse(visit)?; - } - visit.call(&projected.fields.api_key)?; - if let Some(provider) = &projected.fields.azure_ad_token_provider { - provider.traverse(visit)?; - } - visit.call(&projected.retained_fields)?; - visit.call(&projected.body)?; - visit.call(&projected.headers) - } - OcrHostData::Released => Ok(()), - } - } -} - -pub(super) struct BridgeOcrHooks; - -impl litellm_core::ocr::hooks::OcrHooks for BridgeOcrHooks { - fn intercepts_requests(&self) -> bool { - true - } -} - -fn run_ocr( - py: Python<'_>, - request: Bound<'_, PyAny>, - args: Bound<'_, PyTuple>, - kwargs: Bound<'_, PyDict>, - asynchronous: bool, -) -> PyResult> { - let client = OcrClient::shared().map_err(ocr_error_to_pyerr)?; - let call = admitted_call(OcrCall::admit( - client, - OcrAdmission { - asynchronous, - ..OcrAdmission::all() - }, - ))?; - let host = PythonOcrHost { - state: PythonCallState::new( - py, - args.unbind(), - kwargs.copy()?.unbind(), - asynchronous, - if asynchronous { "aocr" } else { "ocr" }, - )?, - data: OcrHostData::Unprojected { - request: request.unbind(), - }, - }; - run_call(py, call, host) -} - -#[pyfunction] -fn ocr( - py: Python<'_>, - request: Bound<'_, PyAny>, - args: Bound<'_, PyTuple>, - kwargs: Bound<'_, PyDict>, -) -> PyResult> { - run_ocr(py, request, args, kwargs, false) -} - -#[pyfunction] -fn aocr( - py: Python<'_>, - request: Bound<'_, PyAny>, - args: Bound<'_, PyTuple>, - kwargs: Bound<'_, PyDict>, -) -> PyResult> { - run_ocr(py, request, args, kwargs, true) -} - -pub(super) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { - module.add_function(wrap_pyfunction!(ocr, module)?)?; - module.add_function(wrap_pyfunction!(aocr, module)?) -} diff --git a/litellm-rust/crates/python-bridge/src/routes/ocr/mod.rs b/litellm-rust/crates/python-bridge/src/routes/ocr/mod.rs index b7f9613a5a0..87590b52dd5 100644 --- a/litellm-rust/crates/python-bridge/src/routes/ocr/mod.rs +++ b/litellm-rust/crates/python-bridge/src/routes/ocr/mod.rs @@ -1,11 +1,59 @@ -mod callbacks; mod document; mod errors; -mod lifecycle; +mod host; mod project; +use litellm_callbacks_legacy::{LegacySurface, PublicCall, run_legacy_call}; +use litellm_core::ocr::{OcrClient, ocr_machine}; use pyo3::prelude::*; +use pyo3::types::{PyDict, PyTuple}; -pub(super) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { - lifecycle::register(module) +use host::OcrRouteHost; + +const SURFACE: LegacySurface = LegacySurface { + call_type: "ocr", + input_description: "OCR document processing", +}; + +const ASYNC_SURFACE: LegacySurface = LegacySurface { + call_type: "aocr", + ..SURFACE +}; + +fn run_ocr( + py: Python<'_>, + request: Bound<'_, PyAny>, + args: Bound<'_, PyTuple>, + kwargs: Bound<'_, PyDict>, + asynchronous: bool, +) -> PyResult> { + let client = OcrClient::shared().map_err(errors::to_pyerr)?; + run_legacy_call( + py, + if asynchronous { ASYNC_SURFACE } else { SURFACE }, + PublicCall::capture(&request, &args, &kwargs)?, + ocr_machine(client), + OcrRouteHost::new(request.unbind()), + asynchronous, + ) +} + +#[pyfunction] +pub(crate) fn ocr( + py: Python<'_>, + request: Bound<'_, PyAny>, + args: Bound<'_, PyTuple>, + kwargs: Bound<'_, PyDict>, +) -> PyResult> { + run_ocr(py, request, args, kwargs, false) +} + +#[pyfunction] +pub(crate) fn aocr( + py: Python<'_>, + request: Bound<'_, PyAny>, + args: Bound<'_, PyTuple>, + kwargs: Bound<'_, PyDict>, +) -> PyResult> { + run_ocr(py, request, args, kwargs, true) } diff --git a/litellm-rust/crates/python-bridge/src/routes/ocr/project.rs b/litellm-rust/crates/python-bridge/src/routes/ocr/project.rs index e2fe7ae4109..314bdec0e1b 100644 --- a/litellm-rust/crates/python-bridge/src/routes/ocr/project.rs +++ b/litellm-rust/crates/python-bridge/src/routes/ocr/project.rs @@ -1,34 +1,24 @@ -use std::sync::Arc; - use litellm_core::ocr::wire::{ OcrWireRequest, consumed_optional_params, decode_document, decode_request_input, }; -use litellm_core::ocr::{LiteLLMOcrRequest, NativeOutcome, OcrCall, OcrDocumentInput}; -use litellm_python_interop::from_py_preserving_errors as from_py; +use litellm_core::ocr::{LiteLLMOcrRequest, OcrDocumentInput}; +use litellm_host_python::from_py; +use pyo3::exceptions::PyValueError; use pyo3::prelude::*; use pyo3::types::PyDict; use serde_json::{Map, Value}; use super::document::{FileDocumentInput, PythonFileReader}; use super::errors::to_pyerr as ocr_error_to_pyerr; -use super::lifecycle::BridgeOcrHooks; -use crate::auth::{AZURE_AD_TOKEN_PROVIDER, PythonTokenProvider}; -use crate::errors::RustBridgeDeclined; +use crate::credentials::{self, CallerTokenProvider}; use crate::marshal::{project_optional_fields, python_timeout_seconds, request_input_sources}; -pub(super) struct ProjectedOcrFields { - pub boundary_request: Py, - pub document: Option>, +/// What the host keeps after projection: the caller's callables that answer the document +/// read and token operations, and the provider name the failure mapping reports. +pub(super) struct OcrHostHandles { pub reader: Option, - pub api_key: Py, - pub azure_ad_token_provider: Option, + pub azure_ad_token_provider: Option, pub provider: &'static str, - pub secret_fields: Vec<&'static str>, -} - -pub(super) struct ProjectedOcrCall { - pub request: LiteLLMOcrRequest, - pub fields: ProjectedOcrFields, } struct OcrArguments<'a, 'py> { @@ -38,10 +28,8 @@ struct OcrArguments<'a, 'py> { impl<'py> OcrArguments<'_, 'py> { fn lookup(&self, name: &str) -> PyResult> { - match self.kwargs.get_item(name)? { - Some(value) => Ok(value), - None => self.request.getattr(name), - } + litellm_callbacks_legacy::lookup(self.kwargs, self.request, name)? + .ok_or_else(|| PyValueError::new_err(format!("missing argument: {name}"))) } fn model(&self) -> PyResult { @@ -56,8 +44,8 @@ impl<'py> OcrArguments<'_, 'py> { self.lookup("document") } - fn api_key(&self) -> PyResult> { - self.lookup("api_key") + fn api_key(&self) -> PyResult> { + self.lookup("api_key")?.extract() } fn api_base(&self) -> PyResult> { @@ -83,7 +71,7 @@ impl<'py> OcrArguments<'_, 'py> { enum ProjectedDocument { File(FileDocumentInput), - Other { wire: Value, retained: Py }, + Other(Value), } impl ProjectedDocument { @@ -104,26 +92,16 @@ impl ProjectedDocument { } })?; if kind != "file" { - return Ok(Self::Other { - wire: from_py(document)?, - retained: document.clone().unbind(), - }); + return Ok(Self::Other(from_py(document)?)); } Ok(Self::File(document.extract()?)) } - fn into_parts( - self, - ) -> PyResult<( - OcrDocumentInput, - Option>, - Option, - )> { + fn into_parts(self) -> PyResult<(OcrDocumentInput, Option)> { match self { - Self::File(FileDocumentInput { input, reader }) => Ok((input, None, reader)), - Self::Other { wire, retained } => Ok(( + Self::File(FileDocumentInput { input, reader }) => Ok((input, reader)), + Self::Other(wire) => Ok(( decode_document(wire).map_err(ocr_error_to_pyerr)?.into(), - Some(retained), None, )), } @@ -133,8 +111,7 @@ impl ProjectedDocument { pub(super) fn project_request( request: &Bound<'_, PyAny>, kwargs: &Bound<'_, PyDict>, -) -> PyResult { - let boundary_request = request.clone().unbind(); +) -> PyResult<(LiteLLMOcrRequest, OcrHostHandles)> { let arguments = OcrArguments { request, kwargs }; let model = arguments.model()?; let custom_llm_provider = arguments.custom_llm_provider()?; @@ -151,14 +128,12 @@ pub(super) fn project_request( .copied() .chain(["api_key", "api_base", "extra_headers"]), )?; - let azure_ad_token_provider = kwargs - .get_item("azure_ad_token_provider")? - .and_then(|provider| PythonTokenProvider::select(provider, AZURE_AD_TOKEN_PROVIDER)); - let (document, retained_document, reader) = document.into_parts()?; + let azure_ad_token_provider = credentials::azure_ad_token_provider(kwargs)?; + let (document, reader) = document.into_parts()?; let wire = OcrWireRequest { model, document, - api_key: api_key.extract()?, + api_key, api_base: arguments.api_base()?, custom_llm_provider, extra_headers: arguments.extra_headers()?, @@ -168,37 +143,18 @@ pub(super) fn project_request( }; let request = decode_request_input(wire).map_err(ocr_error_to_pyerr)?; let provider = request.provider_name(); - Ok(ProjectedOcrCall { - request: request.with_host_hooks(Arc::new(BridgeOcrHooks), None), - fields: ProjectedOcrFields { - boundary_request, - document: retained_document, + Ok(( + request, + OcrHostHandles { reader, - api_key: api_key.unbind(), azure_ad_token_provider, provider, - secret_fields: specs - .into_iter() - .filter(|spec| spec.secret) - .map(|spec| spec.name) - .collect(), }, - }) -} - -pub(super) fn admitted_call(outcome: NativeOutcome) -> PyResult { - match outcome { - NativeOutcome::Completed(call) => Ok(call), - NativeOutcome::Declined(reason) => Err(RustBridgeDeclined::new_err(format!( - "native OCR admission declined: {reason:?}" - ))), - } + )) } #[cfg(test)] mod tests { - use litellm_core::ocr::Error; - use litellm_core::ocr::OcrDecline; use pyo3::exceptions::PyValueError; use super::*; @@ -218,11 +174,7 @@ mod tests { fn project_document( document: &Bound<'_, PyAny>, - ) -> PyResult<( - OcrDocumentInput, - Option>, - Option, - )> { + ) -> PyResult<(OcrDocumentInput, Option)> { ProjectedDocument::project(document)?.into_parts() } @@ -249,28 +201,6 @@ sys.modules['litellm.rust_bridge.timeouts'] = timeouts ); } - #[test] - fn typed_initial_decline_uses_bridge_decline_contract() { - Python::initialize(); - Python::attach(|py| { - let Err(error) = admitted_call(NativeOutcome::Declined(OcrDecline::HostOperations)) - else { - panic!("unsupported host operations should decline admission"); - }; - assert!(error.is_instance_of::(py)); - }); - } - - #[test] - fn post_admission_error_does_not_use_bridge_decline_contract() { - Python::initialize(); - Python::attach(|py| { - let error = ocr_error_to_pyerr(Error::InvalidRequest("callback result".into())); - assert!(error.is_instance_of::(py)); - assert!(!error.is_instance_of::(py)); - }); - } - #[test] fn kwargs_override_request_attributes_including_explicit_none() { Python::initialize(); @@ -437,9 +367,8 @@ kwargs = {} .unwrap(); let arguments = arguments(&request, &kwargs); let document = arguments.document().unwrap(); - let (input, retained, reader) = project_document(&document).unwrap(); + let (input, reader) = project_document(&document).unwrap(); assert_eq!(input, OcrDocumentInput::HostReader { mime_type: None }); - assert!(retained.is_none()); assert_eq!(arguments.api_base().unwrap().as_deref(), Some("original")); assert_eq!(arguments.timeout_seconds().unwrap(), Some(1.0)); reader.unwrap().read(py).unwrap(); @@ -449,38 +378,7 @@ kwargs = {} } #[test] - fn captured_api_key_keeps_the_original_python_object() { - Python::initialize(); - Python::attach(|py| { - let locals = eval( - py, - c" -key = object() -class Request: - api_key = None -request = Request() -kwargs = {'api_key': key} -", - ); - let request = locals.get_item("request").unwrap().unwrap(); - let kwargs = locals - .get_item("kwargs") - .unwrap() - .unwrap() - .cast_into::() - .unwrap(); - let captured = arguments(&request, &kwargs).api_key().unwrap(); - assert!( - captured - .unbind() - .bind(py) - .is(locals.get_item("key").unwrap().unwrap()) - ); - }); - } - - #[test] - fn file_documents_become_typed_inputs_and_other_documents_keep_the_python_object() { + fn file_documents_become_typed_inputs_and_other_documents_decode() { Python::initialize(); Python::attach(|py| { let file = py @@ -490,7 +388,7 @@ kwargs = {'api_key': key} None, ) .unwrap(); - let (input, retained, reader) = project_document(&file).unwrap(); + let (input, reader) = project_document(&file).unwrap(); assert_eq!( input, OcrDocumentInput::Bytes { @@ -499,7 +397,6 @@ kwargs = {'api_key': key} mime_type: Some("application/pdf".into()), } ); - assert!(retained.is_none()); assert!(reader.is_none()); let original = py @@ -509,9 +406,8 @@ kwargs = {'api_key': key} None, ) .unwrap(); - let (input, retained, _) = project_document(&original).unwrap(); + let (input, _) = project_document(&original).unwrap(); assert_eq!(input, url_document("https://example.com/a.pdf")); - assert!(retained.unwrap().bind(py).is(&original)); }); } @@ -566,6 +462,133 @@ document = Document() }); } + #[rstest::rstest] + #[case::missing(c"{}")] + #[case::non_string(c"{'type': 1}")] + #[case::list(c"[]")] + fn malformed_document_discriminators_are_bad_requests_naming_the_field( + #[case] document: &std::ffi::CStr, + ) { + Python::initialize(); + Python::attach(|py| { + let error = project_document(&py.eval(document, None, None).unwrap()).unwrap_err(); + let value = error.value(py); + assert!(error.is_instance_of::(py)); + assert_eq!( + value.to_string(), + "invalid OCR request field: document.type" + ); + assert_eq!( + value + .getattr("status_code") + .unwrap() + .extract::() + .unwrap(), + 400 + ); + }); + } + + fn request_and_kwargs<'py>( + py: Python<'py>, + kwargs: &std::ffi::CStr, + ) -> (Bound<'py, PyAny>, Bound<'py, PyDict>) { + let locals = eval( + py, + c" +class Request: + model = 'mistral/mistral-ocr-latest' + custom_llm_provider = 'mistral' + document = {'type': 'document_url', 'document_url': 'https://example.com/request.pdf'} + api_key = None + api_base = 'https://request.example.com' + extra_headers = {'x-source': 'request'} + timeout = 1 +request = Request() +", + ); + py.run(kwargs, Some(&locals), Some(&locals)).unwrap(); + ( + locals.get_item("request").unwrap().unwrap(), + locals + .get_item("kwargs") + .unwrap() + .unwrap() + .cast_into::() + .unwrap(), + ) + } + + #[test] + fn unconsumed_kwargs_stay_out_of_optional_params_and_response_limit_goes_to_transport() { + Python::initialize(); + Python::attach(|py| { + stub_timeout_conversion(py); + let (request, kwargs) = request_and_kwargs( + py, + c" +kwargs = { + 'model': 'mistral/mistral-ocr-latest', + 'custom_llm_provider': None, + 'pages': [0], + 'max_response_bytes': 1234, + 'metadata': {'user_api_key_auth': 'auth'}, + 'ocr_cost_per_page': 0.05, + 'shared_session': object(), + 'guardrails': ['guard'], + 'opaque': object(), +} +", + ); + let (projected, _) = project_request(&request, &kwargs).unwrap(); + assert_eq!( + projected.optional_params.keys().collect::>(), + ["pages"] + ); + assert_eq!(projected.transport.max_response_bytes, 1234); + }); + } + + #[test] + fn replacement_kwargs_project_provider_connection_and_timeout() { + Python::initialize(); + Python::attach(|py| { + stub_timeout_conversion(py); + let (request, kwargs) = request_and_kwargs( + py, + c" +kwargs = { + 'model': 'mistral-ocr-latest', + 'custom_llm_provider': 'azure_ai', + 'document': {'type': 'document_url', 'document_url': 'https://example.com/kwargs.pdf'}, + 'api_base': 'https://kwargs.example.com', + 'extra_headers': {'x-source': 'kwargs'}, + 'timeout': 5, +} +", + ); + let (projected, handles) = project_request(&request, &kwargs).unwrap(); + assert_eq!(handles.provider, "azure_ai"); + assert_eq!(projected.model, "mistral-ocr-latest"); + assert_eq!( + projected.document, + url_document("https://example.com/kwargs.pdf") + ); + assert_eq!( + projected.credentials.api_base.unwrap().value(), + "https://kwargs.example.com" + ); + assert_eq!( + projected.transport.extra_headers, + [("x-source".to_string(), "kwargs".to_string())] + ); + assert_eq!( + projected.transport.timeout, + std::time::Duration::from_secs(5) + ); + }); + } + #[test] fn document_classification_happens_once() { Python::initialize(); @@ -586,9 +609,8 @@ document = Document() ", ); let document = locals.get_item("document").unwrap().unwrap(); - let (input, retained, _) = project_document(&document).unwrap(); + let (input, _) = project_document(&document).unwrap(); assert!(matches!(input, OcrDocumentInput::Bytes { .. })); - assert!(retained.is_none()); let reads: Vec = document.getattr("reads").unwrap().extract().unwrap(); assert_eq!(reads, ["type", "mime_type", "file"]); }); diff --git a/litellm-rust/crates/python-bridge/src/routes/responses.rs b/litellm-rust/crates/python-bridge/src/routes/responses.rs new file mode 100644 index 00000000000..bf48e4619a9 --- /dev/null +++ b/litellm-rust/crates/python-bridge/src/routes/responses.rs @@ -0,0 +1,132 @@ +use litellm_core::responses::websocket::ResponsesWebSocketConnection as RustResponsesWebSocketConnection; +use pyo3::prelude::*; +use serde_json::Value; + +use crate::errors::responses_error_to_pyerr; +use crate::marshal::{marshal_headers, optional_timeout}; + +#[pyclass] +pub(crate) struct ResponsesWebSocketConnection { + inner: RustResponsesWebSocketConnection, +} + +#[pymethods] +impl ResponsesWebSocketConnection { + #[classmethod] + #[pyo3(signature = (url, headers=None, timeout_seconds=None))] + fn connect<'py>( + _cls: &Bound<'py, pyo3::types::PyType>, + py: Python<'py>, + url: String, + #[pyo3(from_py_with = litellm_host_python::from_py_argument)] headers: Option, + timeout_seconds: Option, + ) -> PyResult> { + let headers = marshal_headers(headers)?; + let timeout = optional_timeout(timeout_seconds); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let inner = RustResponsesWebSocketConnection::connect_url(&url, &headers, timeout) + .await + .map_err(responses_error_to_pyerr)?; + Ok(ResponsesWebSocketConnection { inner }) + }) + } + + fn send_text<'py>(&self, py: Python<'py>, text: String) -> PyResult> { + let inner = self.inner.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + inner + .send_text(text) + .await + .map_err(responses_error_to_pyerr) + }) + } + + fn recv_text<'py>(&self, py: Python<'py>) -> PyResult> { + let inner = self.inner.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + inner.recv_text().await.map_err(responses_error_to_pyerr) + }) + } + + fn close<'py>(&self, py: Python<'py>) -> PyResult> { + let inner = self.inner.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + inner.close().await.map_err(responses_error_to_pyerr) + }) + } +} + +#[cfg(test)] +mod tests { + use std::ffi::CString; + use std::time::Duration; + + use futures_util::{SinkExt, StreamExt}; + use pyo3::prelude::*; + use pyo3::types::PyDict; + use tokio::net::TcpListener; + use tokio_tungstenite::{accept_async, tungstenite::Message}; + + #[test] + fn responses_websocket_connection_round_trips_through_python() { + Python::initialize(); + let runtime = pyo3_async_runtimes::tokio::get_runtime(); + let listener = runtime + .block_on(TcpListener::bind("127.0.0.1:0")) + .expect("listener should bind"); + let address = listener + .local_addr() + .expect("listener should have an address"); + let server = runtime.spawn(async move { + let (stream, _) = listener.accept().await.expect("server should accept"); + let mut socket = accept_async(stream) + .await + .expect("handshake should succeed"); + + let message = socket + .next() + .await + .expect("client should send a frame") + .expect("client frame should be valid"); + assert_eq!(message, Message::Text("from-python".into())); + socket + .send(Message::Text("from-server".into())) + .await + .expect("server should reply"); + assert!(matches!(socket.next().await, Some(Ok(Message::Close(_))))); + }); + + Python::attach(|py| { + let locals = PyDict::new(py); + locals + .set_item("native", crate::native_module(py)) + .expect("module should enter Python locals"); + locals + .set_item("url", format!("ws://{address}")) + .expect("URL should enter Python locals"); + let code = CString::new( + r#" +import asyncio + +async def exercise(): + connection = await native.ResponsesWebSocketConnection.connect(url) + assert type(connection) is native.ResponsesWebSocketConnection + await connection.send_text("from-python") + assert await connection.recv_text() == "from-server" + await connection.close() + assert await connection.recv_text() is None + +asyncio.run(asyncio.wait_for(exercise(), timeout=5)) +"#, + ) + .expect("Python source should not contain null bytes"); + py.run(&code, Some(&locals), Some(&locals)) + .expect("Python WebSocket methods should round trip"); + }); + + runtime + .block_on(async { tokio::time::timeout(Duration::from_secs(5), server).await }) + .expect("server should finish") + .expect("server task should not panic"); + } +} diff --git a/litellm-rust/crates/python-bridge/src/token_counter.rs b/litellm-rust/crates/python-bridge/src/token_counter.rs index b4de50c5f1a..117e2b6e6ff 100644 --- a/litellm-rust/crates/python-bridge/src/token_counter.rs +++ b/litellm-rust/crates/python-bridge/src/token_counter.rs @@ -2,7 +2,7 @@ use std::num::NonZero; use std::sync::Arc; use std::thread::available_parallelism; -use litellm_python_interop::release_gil; +use litellm_host_python::release_gil; use litellm_token_counter::{ CountableRequest, Error, InputTokenCount, TokenCounter as CoreTokenCounter, }; @@ -11,9 +11,8 @@ use pyo3::prelude::*; use pyo3::types::PyAny; use tokio::sync::Semaphore; -use crate::constants::TOKEN_COUNT_FALLBACK_PARALLELISM; use crate::errors::RustBridgeDeclined; -use crate::execution::run_async; +use litellm_host_python::run_async; /// Counts the input tokens of a raw request body off the Python event loop with /// the GIL released. Python owns which requests get here and what to do with @@ -21,7 +20,7 @@ use crate::execution::run_async; /// async task, where a cancelled Python awaiter drops them before any blocking /// work is scheduled. #[pyclass(frozen)] -struct TokenCounter { +pub(crate) struct TokenCounter { inner: Arc, encode_slots: Arc, } @@ -77,7 +76,7 @@ impl TokenCounter { } fn encode_parallelism() -> usize { - available_parallelism().map_or(TOKEN_COUNT_FALLBACK_PARALLELISM, NonZero::get) + available_parallelism().map_or(1, NonZero::get) } fn count_body(counter: &CoreTokenCounter, body: &[u8]) -> Result { @@ -99,7 +98,3 @@ fn token_count_error_to_pyerr(error: Error) -> PyErr { Error::Encode(_) | Error::Task(_) => PyRuntimeError::new_err(message), } } - -pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { - module.add_class::() -} diff --git a/litellm-rust/crates/python-bridge/tests/marshal_boundary.rs b/litellm-rust/crates/python-bridge/tests/marshal_boundary.rs index d397d20b9fd..e99c01ae57e 100644 --- a/litellm-rust/crates/python-bridge/tests/marshal_boundary.rs +++ b/litellm-rust/crates/python-bridge/tests/marshal_boundary.rs @@ -41,7 +41,7 @@ fn serialization_uses_the_interop_boundary() { for disallowed in DISALLOWED_OUTSIDE_INTEROP { assert!( !source.contains(disallowed), - "{} bypasses litellm-python-interop with `{disallowed}`", + "{} bypasses litellm-host-python with `{disallowed}`", path.display() ); } diff --git a/litellm-rust/crates/python-interop/src/lib.rs b/litellm-rust/crates/python-interop/src/lib.rs deleted file mode 100644 index 79af79e8c61..00000000000 --- a/litellm-rust/crates/python-interop/src/lib.rs +++ /dev/null @@ -1,7 +0,0 @@ -mod gil; -mod marshal; - -pub use gil::{release_count, release_gil}; -pub use marshal::{ - Pythonized, from_py, from_py_preserving_errors, panic_to_pyerr, to_py, to_py_preserving_errors, -}; diff --git a/litellm/batches/batch_utils.py b/litellm/batches/batch_utils.py index 26b4318da2d..22c105d602e 100644 --- a/litellm/batches/batch_utils.py +++ b/litellm/batches/batch_utils.py @@ -9,6 +9,7 @@ import litellm from litellm._logging import verbose_logger from litellm.litellm_core_utils.get_litellm_params import AWS_CREDENTIAL_KWARGS_KEYS from litellm.litellm_core_utils.llm_cost_calc.utils import parse_prompt_tokens_details +from litellm.llms.bedrock.batches.transformation import titan_embedding_usage_from_batch_output from litellm.llms.vertex_ai.batches.transformation import vertex_prompt_tokens_details from litellm.types.llms.openai import Batch from litellm.types.utils import ModelInfo, Usage @@ -673,6 +674,11 @@ def _get_batch_job_usage_from_response_body( from litellm.llms.anthropic.chat.transformation import AnthropicConfig from litellm.llms.bedrock.chat.converse_transformation import AmazonConverseConfig + titan_usage: Final = ( + titan_embedding_usage_from_batch_output(response_body) if custom_llm_provider == "bedrock" else None + ) + if titan_usage is not None: + return titan_usage usage_object: Final = response_body.get("usage", None) or {} if custom_llm_provider == "bedrock" and AmazonConverseConfig.is_converse_usage_shape(usage_object): return AmazonConverseConfig().usage_from_batch_output(usage_object) diff --git a/litellm/constants.py b/litellm/constants.py index d4827bb7483..6ef3f2ba752 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -605,6 +605,7 @@ LOGGING_EXECUTOR_MAX_THREADS: Final = get_env_int("LOGGING_EXECUTOR_MAX_THREADS" LOGGING_EXECUTOR_MAX_PENDING_TASKS: Final = get_env_int("LOGGING_EXECUTOR_MAX_PENDING_TASKS", 10_000) LOGGING_EXECUTOR_DROPPED_TASK_LOG_INTERVAL_SECONDS: Final = 30.0 AWS_SIGNING_MAX_THREADS: Final = 16 +PROMPT_INJECTION_HEURISTICS_MAX_THREADS: Final = max(1, get_env_int("PROMPT_INJECTION_HEURISTICS_MAX_THREADS", 1)) DD_TRACER_STREAMING_CHUNK_YIELD_RESOURCE: Final = os.getenv( "DD_TRACER_STREAMING_CHUNK_YIELD_RESOURCE", "streaming.chunk.yield" ) diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index bbae3021677..99b0d40f0c0 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -574,7 +574,6 @@ class Logging(LiteLLMLoggingBaseClass): self.streaming_chunks: list[Any] = [] # for generating complete stream response self.sync_streaming_chunks: list[Any] = [] # for generating complete stream response self.log_raw_request_response = log_raw_request_response - self._native_callback_fast_path: bool = False # Initialize dynamic callbacks self.dynamic_input_callbacks: list[str | Callable | CustomLogger] | None = dynamic_input_callbacks diff --git a/litellm/llms/bedrock/batches/transformation.py b/litellm/llms/bedrock/batches/transformation.py index 7729cdfdb0d..ae0f8c5935b 100644 --- a/litellm/llms/bedrock/batches/transformation.py +++ b/litellm/llms/bedrock/batches/transformation.py @@ -1,6 +1,7 @@ import os import re import time +from collections.abc import Mapping from typing import TYPE_CHECKING, Any, Final, Literal, cast from httpx import Headers, Response @@ -26,7 +27,7 @@ from litellm.types.llms.openai import ( AllMessageValues, CreateBatchRequest, ) -from litellm.types.utils import LiteLLMBatch, LlmProviders +from litellm.types.utils import LiteLLMBatch, LlmProviders, Usage from ..base_aws_llm import BaseAWSLLM from ..common_utils import ( @@ -60,6 +61,20 @@ def _validate_bedrock_tags(raw_tags: object) -> list[BedrockTag]: ) from e +def titan_embedding_usage_from_batch_output(model_output: Mapping[str, object]) -> Usage | None: + """Titan embedding batch lines report usage as a top-level inputTextTokenCount, not a usage block.""" + if "embedding" not in model_output and "embeddingsByType" not in model_output: + return None + input_text_token_count: Final = model_output.get("inputTextTokenCount") + if isinstance(input_text_token_count, bool) or not isinstance(input_text_token_count, int): + return None + return Usage( + prompt_tokens=input_text_token_count, + completion_tokens=0, + total_tokens=input_text_token_count, + ) + + class BedrockBatchesConfig(BaseAWSLLM, BaseBatchesConfig): """ Config for Bedrock Batches - handles batch job creation and management for Bedrock diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index e903b239881..92df776adf4 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -40682,6 +40682,9 @@ "supports_system_messages": true }, "openrouter/anthropic/claude-3-haiku": { + "cache_creation_input_token_cost": 3e-07, + "cache_creation_input_token_cost_above_1hr": 5e-07, + "cache_read_input_token_cost": 3e-08, "input_cost_per_image": 0.0004, "input_cost_per_token": 2.5e-07, "litellm_provider": "openrouter", @@ -40692,7 +40695,14 @@ "supports_tool_choice": true, "supports_vision": true, "max_input_tokens": 200000, - "max_output_tokens": 4096 + "max_output_tokens": 4096, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": false, + "supports_web_search": false }, "openrouter/anthropic/claude-3.5-sonnet": { "input_cost_per_token": 3e-06, @@ -40727,6 +40737,7 @@ "openrouter/anthropic/claude-opus-4": { "input_cost_per_image": 0.0048, "cache_creation_input_token_cost": 1.875e-05, + "cache_creation_input_token_cost_above_1hr": 3e-05, "cache_read_input_token_cost": 1.5e-06, "input_cost_per_token": 1.5e-05, "litellm_provider": "openrouter", @@ -40742,7 +40753,12 @@ "supports_reasoning": true, "supports_tool_choice": true, "supports_vision": true, - "prompt_cache_min_tokens": 1024 + "prompt_cache_min_tokens": 1024, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_pdf_input": true, + "supports_response_schema": false, + "supports_web_search": false }, "openrouter/anthropic/claude-opus-4.1": { "input_cost_per_image": 0.0048, @@ -40763,11 +40779,17 @@ "supports_reasoning": true, "supports_tool_choice": true, "supports_vision": true, - "prompt_cache_min_tokens": 1024 + "prompt_cache_min_tokens": 1024, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_pdf_input": true, + "supports_response_schema": false, + "supports_web_search": false }, "openrouter/anthropic/claude-sonnet-4": { "input_cost_per_image": 0.0048, "cache_creation_input_token_cost": 3.75e-06, + "cache_creation_input_token_cost_above_1hr": 6e-06, "cache_creation_input_token_cost_above_200k_tokens": 7.5e-06, "cache_read_input_token_cost": 3e-07, "cache_read_input_token_cost_above_200k_tokens": 6e-07, @@ -40787,12 +40809,18 @@ "supports_reasoning": true, "supports_tool_choice": true, "supports_vision": true, - "prompt_cache_min_tokens": 1024 + "prompt_cache_min_tokens": 1024, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_pdf_input": true, + "supports_response_schema": false, + "supports_web_search": false }, "openrouter/anthropic/claude-sonnet-4.6": { "supports_adaptive_thinking": true, "supports_legacy_thinking": true, "cache_creation_input_token_cost": 3.75e-06, + "cache_creation_input_token_cost_above_1hr": 6e-06, "cache_creation_input_token_cost_above_200k_tokens": 7.5e-06, "cache_read_input_token_cost": 3e-07, "cache_read_input_token_cost_above_200k_tokens": 6e-07, @@ -40805,7 +40833,7 @@ "mode": "chat", "output_cost_per_token": 1.5e-05, "output_cost_per_token_above_200k_tokens": 2.25e-05, - "source": "https://openrouter.ai/anthropic/claude-sonnet-4.6", + "source": "https://openrouter.ai/api/v1/models", "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, @@ -40814,10 +40842,15 @@ "supports_max_reasoning_effort": true, "supports_tool_choice": true, "supports_vision": true, - "prompt_cache_min_tokens": 1024 + "prompt_cache_min_tokens": 1024, + "supports_audio_input": false, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_web_search": false }, "openrouter/anthropic/claude-opus-4.5": { "cache_creation_input_token_cost": 6.25e-06, + "cache_creation_input_token_cost_above_1hr": 1e-05, "cache_read_input_token_cost": 5e-07, "input_cost_per_token": 5e-06, "litellm_provider": "openrouter", @@ -40835,12 +40868,17 @@ "supports_vision": true, "supports_output_config": true, "prompt_cache_min_tokens": 4096, - "source": "https://openrouter.ai/anthropic/claude-opus-4.5" + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_web_search": false }, "openrouter/anthropic/claude-opus-4.6": { "supports_adaptive_thinking": true, "supports_legacy_thinking": true, "cache_creation_input_token_cost": 6.25e-06, + "cache_creation_input_token_cost_above_1hr": 1e-05, "cache_read_input_token_cost": 5e-07, "input_cost_per_token": 5e-06, "litellm_provider": "openrouter", @@ -40859,11 +40897,15 @@ "supports_vision": true, "prompt_cache_min_tokens": 4096, "supports_response_schema": true, - "source": "https://openrouter.ai/api/v1/models" + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_pdf_input": true, + "supports_web_search": false }, "openrouter/anthropic/claude-sonnet-4.5": { "input_cost_per_image": 0.0048, "cache_creation_input_token_cost": 3.75e-06, + "cache_creation_input_token_cost_above_1hr": 6e-06, "cache_read_input_token_cost": 3e-07, "input_cost_per_token": 3e-06, "input_cost_per_token_above_200k_tokens": 6e-06, @@ -40871,7 +40913,7 @@ "cache_creation_input_token_cost_above_200k_tokens": 7.5e-06, "cache_read_input_token_cost_above_200k_tokens": 6e-07, "litellm_provider": "openrouter", - "max_input_tokens": 200000, + "max_input_tokens": 1000000, "max_output_tokens": 64000, "max_tokens": 64000, "mode": "chat", @@ -40884,10 +40926,15 @@ "supports_tool_choice": true, "supports_vision": true, "prompt_cache_min_tokens": 1024, - "source": "https://openrouter.ai/anthropic/claude-sonnet-4.5" + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_web_search": false }, "openrouter/anthropic/claude-haiku-4.5": { "cache_creation_input_token_cost": 1.25e-06, + "cache_creation_input_token_cost_above_1hr": 2e-06, "cache_read_input_token_cost": 1e-07, "input_cost_per_token": 1e-06, "litellm_provider": "openrouter", @@ -40904,11 +40951,16 @@ "supports_tool_choice": true, "supports_vision": true, "prompt_cache_min_tokens": 4096, - "source": "https://openrouter.ai/anthropic/claude-haiku-4.5" + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_web_search": false }, "openrouter/anthropic/claude-opus-4.7": { "supports_adaptive_thinking": true, "cache_creation_input_token_cost": 6.25e-06, + "cache_creation_input_token_cost_above_1hr": 1e-05, "cache_read_input_token_cost": 5e-07, "input_cost_per_token": 5e-06, "litellm_provider": "openrouter", @@ -40928,12 +40980,16 @@ "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, - "prompt_cache_min_tokens": 2048 + "prompt_cache_min_tokens": 2048, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_web_search": false }, "openrouter/anthropic/claude-opus-5": { "prompt_cache_min_tokens": 512, "supports_adaptive_thinking": true, "cache_creation_input_token_cost": 6.25e-06, + "cache_creation_input_token_cost_above_1hr": 1e-05, "cache_read_input_token_cost": 5e-07, "input_cost_per_token": 5e-06, "litellm_provider": "openrouter", @@ -40942,8 +40998,9 @@ "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 2.5e-05, - "source": "https://openrouter.ai/anthropic/claude-opus-5", + "source": "https://openrouter.ai/api/v1/models", "supports_assistant_prefill": false, + "supports_audio_input": false, "supports_computer_use": true, "supports_function_calling": true, "supports_pdf_input": true, @@ -40953,49 +41010,74 @@ "supports_max_reasoning_effort": true, "supports_tool_choice": true, "supports_vision": true, + "supports_web_search": false, "supports_xhigh_reasoning_effort": true }, "openrouter/bytedance/ui-tars-1.5-7b": { + "cache_read_input_token_cost": 1e-07, "input_cost_per_token": 1e-07, "litellm_provider": "openrouter", - "max_input_tokens": 131072, + "max_input_tokens": 128000, "max_output_tokens": 2048, "max_tokens": 2048, "mode": "chat", "output_cost_per_token": 2e-07, - "source": "https://openrouter.ai/bytedance/ui-tars-1.5-7b", - "supports_tool_choice": true + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": true, + "supports_web_search": false }, "openrouter/deepseek/deepseek-chat": { - "input_cost_per_token": 2.574e-07, + "input_cost_per_token": 3.2e-07, "litellm_provider": "openrouter", - "max_input_tokens": 65536, - "max_output_tokens": 8192, - "max_tokens": 8192, + "max_input_tokens": 163840, + "max_output_tokens": 16384, + "max_tokens": 16384, "mode": "chat", - "output_cost_per_token": 1.0287e-06, - "supports_prompt_caching": true, + "output_cost_per_token": 8.9e-07, + "supports_prompt_caching": false, "supports_tool_choice": true, - "source": "https://openrouter.ai/api/v1/models" + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_vision": false, + "supports_web_search": false }, "openrouter/deepseek/deepseek-chat-v3-0324": { "input_cost_per_token": 2.5e-07, "litellm_provider": "openrouter", - "max_input_tokens": 65536, - "max_output_tokens": 8192, - "max_tokens": 8192, + "max_input_tokens": 163840, + "max_output_tokens": 147456, + "max_tokens": 147456, "mode": "chat", "output_cost_per_token": 1e-06, - "supports_prompt_caching": true, - "supports_tool_choice": true + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false }, "openrouter/deepseek/deepseek-chat-v3.1": { "input_cost_per_token": 2.5e-07, "input_cost_per_token_cache_hit": 2e-08, "litellm_provider": "openrouter", "max_input_tokens": 163840, - "max_output_tokens": 163840, - "max_tokens": 163840, + "max_output_tokens": 32768, + "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 9.5e-07, "supports_assistant_prefill": true, @@ -41004,9 +41086,15 @@ "supports_reasoning": true, "supports_tool_choice": true, "cache_read_input_token_cost": 1.3e-07, - "source": "https://openrouter.ai/deepseek/deepseek-chat-v3.1" + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_pdf_input": false, + "supports_response_schema": true, + "supports_vision": false, + "supports_web_search": false }, "openrouter/deepseek/deepseek-v3.2": { + "cache_read_input_token_cost": 1.345e-07, "input_cost_per_token": 2.69e-07, "input_cost_per_token_cache_hit": 1.345e-07, "litellm_provider": "openrouter", @@ -41021,69 +41109,96 @@ "supports_reasoning": true, "supports_tool_choice": true, "supports_response_schema": true, - "source": "https://openrouter.ai/api/v1/models" + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_pdf_input": false, + "supports_vision": false, + "supports_web_search": false }, "openrouter/deepseek/deepseek-v3.2-exp": { "input_cost_per_token": 2.7e-07, "input_cost_per_token_cache_hit": 2e-08, "litellm_provider": "openrouter", "max_input_tokens": 163840, - "max_output_tokens": 163840, - "max_tokens": 163840, + "max_output_tokens": 65536, + "max_tokens": 65536, "mode": "chat", "output_cost_per_token": 4.1e-07, + "source": "https://openrouter.ai/api/v1/models", "supports_assistant_prefill": true, + "supports_audio_input": false, "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_reasoning": false, - "supports_tool_choice": true + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false }, "openrouter/deepseek/deepseek-r1": { "input_cost_per_token": 7e-07, "input_cost_per_token_cache_hit": 1.4e-07, "litellm_provider": "openrouter", - "max_input_tokens": 65336, - "max_output_tokens": 8192, - "max_tokens": 8192, + "max_input_tokens": 64000, + "max_output_tokens": 16000, + "max_tokens": 16000, "mode": "chat", "output_cost_per_token": 2.5e-06, + "source": "https://openrouter.ai/api/v1/models", "supports_assistant_prefill": true, + "supports_audio_input": false, "supports_function_calling": true, - "supports_prompt_caching": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_reasoning": true, - "supports_tool_choice": true + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false }, "openrouter/deepseek/deepseek-r1-0528": { + "cache_read_input_token_cost": 3.5e-07, "input_cost_per_token": 5e-07, "input_cost_per_token_cache_hit": 1.4e-07, "litellm_provider": "openrouter", - "max_input_tokens": 65336, - "max_output_tokens": 8192, - "max_tokens": 8192, + "max_input_tokens": 163840, + "max_output_tokens": 32768, + "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 2.15e-06, + "source": "https://openrouter.ai/api/v1/models", "supports_assistant_prefill": true, + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_prompt_caching": true, "supports_reasoning": true, - "supports_tool_choice": true + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false }, "openrouter/deepseek/deepseek-v4-pro": { - "input_cost_per_token": 1.6e-06, + "input_cost_per_token": 9.4336e-07, "input_cost_per_token_cache_hit": 4.4e-08, "litellm_provider": "openrouter", "max_input_tokens": 1048576, - "max_output_tokens": 384000, - "max_tokens": 384000, + "max_output_tokens": 393216, + "max_tokens": 393216, "mode": "chat", - "output_cost_per_token": 3.2e-06, - "source": "https://openrouter.ai/deepseek/deepseek-v4-pro", + "output_cost_per_token": 1.88672e-06, + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "cache_read_input_token_cost": 1.35e-07 + "cache_read_input_token_cost": 7.9596e-08, + "supports_audio_input": false, + "supports_pdf_input": false, + "supports_vision": false, + "supports_web_search": false }, "openrouter/deepseek/deepseek-v4.1-flash": { "input_cost_per_token": 1.5e-07, @@ -41094,31 +41209,37 @@ "max_output_tokens": 384000, "max_tokens": 384000, "mode": "chat", - "source": "https://openrouter.ai/deepseek/deepseek-v4.1-flash", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, "supports_pdf_input": false, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/deepseek/deepseek-v4-pro-0813": { - "input_cost_per_token": 5.7948e-07, + "input_cost_per_token": 6.6e-07, "input_cost_per_token_cache_hit": 4.4e-08, "litellm_provider": "openrouter", "max_input_tokens": 1048576, - "max_output_tokens": 384000, - "max_tokens": 384000, + "max_output_tokens": 393216, + "max_tokens": 393216, "mode": "chat", - "output_cost_per_token": 1.73844e-06, - "source": "https://openrouter.ai/deepseek/deepseek-v4-pro-0813", + "output_cost_per_token": 1.98e-06, + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "cache_read_input_token_cost": 1.9316e-08 + "cache_read_input_token_cost": 2.2e-08, + "supports_audio_input": false, + "supports_pdf_input": false, + "supports_vision": false, + "supports_web_search": false }, "openrouter/google/gemini-2.0-flash-001": { "deprecation_date": "2026-06-01", @@ -41138,7 +41259,9 @@ "supports_vision": true }, "openrouter/google/gemini-2.5-flash": { - "input_cost_per_audio_token": 7e-07, + "cache_creation_input_token_cost": 8.33333333333333e-08, + "cache_read_input_audio_token_cost": 1e-07, + "input_cost_per_audio_token": 1e-06, "input_cost_per_token": 3e-07, "litellm_provider": "openrouter", "max_input_tokens": 1048576, @@ -41155,15 +41278,21 @@ "supports_image_size": false, "cache_read_input_token_cost": 3e-08, "supports_prompt_caching": true, - "source": "https://openrouter.ai/google/gemini-2.5-flash" + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": true, + "supports_pdf_input": true, + "supports_reasoning": true, + "supports_web_search": false }, "openrouter/google/gemini-2.5-pro": { - "input_cost_per_audio_token": 7e-07, + "cache_creation_input_token_cost": 3.75e-07, + "cache_read_input_audio_token_cost": 1.25e-07, + "input_cost_per_audio_token": 1.25e-06, "input_cost_per_token": 1.25e-06, "litellm_provider": "openrouter", "max_input_tokens": 1048576, - "max_output_tokens": 65535, - "max_tokens": 65535, + "max_output_tokens": 65536, + "max_tokens": 65536, "mode": "chat", "output_cost_per_token": 1e-05, "supports_audio_output": true, @@ -41173,8 +41302,15 @@ "supports_tool_choice": true, "supports_vision": true, "cache_read_input_token_cost": 1.25e-07, + "cache_read_input_token_cost_above_200k_tokens": 2.5e-07, + "input_cost_per_token_above_200k_tokens": 2.5e-06, + "output_cost_per_token_above_200k_tokens": 1.5e-05, "supports_prompt_caching": true, - "source": "https://openrouter.ai/google/gemini-2.5-pro" + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": true, + "supports_pdf_input": true, + "supports_reasoning": true, + "supports_web_search": false }, "openrouter/google/gemini-3-pro-preview": { "cache_read_input_token_cost": 2e-07, @@ -41218,18 +41354,20 @@ "supports_web_search": true }, "openrouter/google/gemini-3-flash-preview": { + "cache_creation_input_token_cost": 8.33333333333333e-08, + "cache_read_input_audio_token_cost": 1e-07, "cache_read_input_token_cost": 5e-08, "input_cost_per_audio_token": 1e-06, "input_cost_per_token": 5e-07, "litellm_provider": "openrouter", "max_input_tokens": 1048576, - "max_output_tokens": 65535, - "max_tokens": 65535, + "max_output_tokens": 65536, + "max_tokens": 65536, "mode": "chat", "output_cost_per_reasoning_token": 3e-06, "output_cost_per_token": 3e-06, "rpm": 2000, - "source": "https://ai.google.dev/gemini-api/docs/pricing", + "source": "https://openrouter.ai/api/v1/models", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -41244,6 +41382,7 @@ "supported_output_modalities": [ "text" ], + "supports_audio_input": true, "supports_audio_output": false, "supports_function_calling": true, "supports_parallel_function_calling": true, @@ -41255,10 +41394,12 @@ "supports_tool_choice": true, "supports_url_context": true, "supports_vision": true, - "supports_web_search": true, + "supports_web_search": false, "tpm": 800000 }, "openrouter/google/gemini-3.1-flash-lite-preview": { + "cache_creation_input_token_cost": 8.33333333333333e-08, + "cache_read_input_audio_token_cost": 5e-08, "cache_read_input_token_cost": 2.5e-08, "input_cost_per_audio_token": 5e-07, "input_cost_per_token": 2.5e-07, @@ -41270,7 +41411,7 @@ "output_cost_per_reasoning_token": 1.5e-06, "output_cost_per_token": 1.5e-06, "rpm": 2000, - "source": "https://ai.google.dev/gemini-api/docs/pricing", + "source": "https://openrouter.ai/api/v1/models", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -41298,10 +41439,12 @@ "supports_url_context": true, "supports_video_input": true, "supports_vision": true, - "supports_web_search": true, + "supports_web_search": false, "tpm": 800000 }, "openrouter/google/gemini-3.1-flash-lite": { + "cache_creation_input_token_cost": 8.33333333333333e-08, + "cache_read_input_audio_token_cost": 5e-08, "cache_read_input_token_cost": 2.5e-08, "input_cost_per_audio_token": 5e-07, "input_cost_per_token": 2.5e-07, @@ -41313,7 +41456,7 @@ "output_cost_per_reasoning_token": 1.5e-06, "output_cost_per_token": 1.5e-06, "rpm": 2000, - "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-3.1-flash-lite", + "source": "https://openrouter.ai/api/v1/models", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -41341,13 +41484,16 @@ "supports_url_context": true, "supports_video_input": true, "supports_vision": true, - "supports_web_search": true, + "supports_web_search": false, "tpm": 800000 }, "openrouter/google/gemini-3.1-pro-preview": { + "cache_creation_input_token_cost": 3.75e-07, "cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_above_200k_tokens": 4e-07, "cache_creation_input_token_cost_above_200k_tokens": 2.5e-07, + "cache_read_input_audio_token_cost": 2e-07, + "input_cost_per_audio_token": 2e-06, "input_cost_per_token": 2e-06, "input_cost_per_token_above_200k_tokens": 4e-06, "litellm_provider": "openrouter", @@ -41357,7 +41503,7 @@ "mode": "chat", "output_cost_per_token": 1.2e-05, "output_cost_per_token_above_200k_tokens": 1.8e-05, - "source": "https://openrouter.ai/google/gemini-3.1-pro-preview", + "source": "https://openrouter.ai/api/v1/models", "supported_modalities": [ "text", "image", @@ -41375,26 +41521,46 @@ "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/gryphe/mythomax-l2-13b": { "input_cost_per_token": 8e-08, "litellm_provider": "openrouter", - "max_tokens": 8192, + "max_input_tokens": 8192, + "max_output_tokens": 3686, + "max_tokens": 3686, "mode": "chat", "output_cost_per_token": 1.1e-07, - "supports_tool_choice": true, - "source": "https://openrouter.ai/api/v1/models" + "supports_tool_choice": false, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_vision": false, + "supports_web_search": false }, "openrouter/mancer/weaver": { "input_cost_per_token": 4e-07, "litellm_provider": "openrouter", - "max_tokens": 2000, + "max_tokens": 6000, "mode": "chat", "output_cost_per_token": 7.5e-07, - "supports_tool_choice": true, + "supports_tool_choice": false, "max_input_tokens": 8000, - "max_output_tokens": 2000 + "max_output_tokens": 6000, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_vision": false, + "supports_web_search": false }, "openrouter/meta-llama/llama-3-70b-instruct": { "input_cost_per_token": 5.9e-07, @@ -41410,84 +41576,125 @@ "input_cost_per_token": 2.55e-07, "litellm_provider": "openrouter", "max_input_tokens": 204800, - "max_output_tokens": 204800, - "max_tokens": 204800, + "max_output_tokens": 131072, + "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 1.02e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, - "supports_prompt_caching": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_reasoning": true, - "supports_tool_choice": true + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false }, "openrouter/mistralai/devstral-2512": { + "cache_read_input_token_cost": 4e-08, "input_cost_per_image": 0, "input_cost_per_token": 4e-07, "litellm_provider": "openrouter", "max_input_tokens": 262144, - "max_output_tokens": 65536, - "max_tokens": 65536, + "max_output_tokens": 209715, + "max_tokens": 209715, "mode": "chat", "output_cost_per_token": 2e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, - "supports_prompt_caching": false, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": false + "supports_vision": false, + "supports_web_search": false }, "openrouter/mistralai/ministral-3b-2512": { + "cache_read_input_token_cost": 1e-08, "input_cost_per_image": 0, "input_cost_per_token": 1e-07, "litellm_provider": "openrouter", "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, + "max_output_tokens": 104857, + "max_tokens": 104857, "mode": "chat", "output_cost_per_token": 1e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, - "supports_prompt_caching": false, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/mistralai/ministral-8b-2512": { + "cache_read_input_token_cost": 1.5e-08, "input_cost_per_image": 0, "input_cost_per_token": 1.5e-07, "litellm_provider": "openrouter", "max_input_tokens": 262144, - "max_output_tokens": 262144, - "max_tokens": 262144, + "max_output_tokens": 209715, + "max_tokens": 209715, "mode": "chat", "output_cost_per_token": 1.5e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, - "supports_prompt_caching": false, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/mistralai/ministral-14b-2512": { + "cache_read_input_token_cost": 2e-08, "input_cost_per_image": 0, "input_cost_per_token": 2e-07, "litellm_provider": "openrouter", "max_input_tokens": 262144, - "max_output_tokens": 262144, - "max_tokens": 262144, + "max_output_tokens": 209715, + "max_tokens": 209715, "mode": "chat", "output_cost_per_token": 2e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, - "supports_prompt_caching": false, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/mistralai/mistral-large-2512": { + "cache_read_input_token_cost": 5.5e-08, "input_cost_per_image": 0, - "input_cost_per_token": 5e-07, + "input_cost_per_token": 5.5e-07, "litellm_provider": "openrouter", "max_input_tokens": 262144, - "max_output_tokens": 262144, - "max_tokens": 262144, + "max_output_tokens": 209715, + "max_tokens": 209715, "mode": "chat", - "output_cost_per_token": 1.5e-06, + "output_cost_per_token": 1.65e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, - "supports_prompt_caching": false, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/mistralai/mistral-7b-instruct": { "input_cost_per_token": 1.3e-07, @@ -41500,71 +41707,123 @@ "max_output_tokens": 8191 }, "openrouter/mistralai/mistral-large": { + "cache_read_input_token_cost": 2e-07, "input_cost_per_token": 2e-06, "litellm_provider": "openrouter", - "max_tokens": 8191, + "max_tokens": 102400, "mode": "chat", "output_cost_per_token": 6e-06, "supports_tool_choice": true, "max_input_tokens": 128000, - "max_output_tokens": 8191 + "max_output_tokens": 102400, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_vision": false, + "supports_web_search": false }, "openrouter/mistralai/mistral-small-3.1-24b-instruct": { "input_cost_per_token": 3.51e-07, "litellm_provider": "openrouter", - "max_tokens": 131072, + "max_tokens": 102400, "mode": "chat", "output_cost_per_token": 5.55e-07, - "supports_tool_choice": true, - "max_input_tokens": 131072, - "max_output_tokens": 131072 + "supports_tool_choice": false, + "max_input_tokens": 128000, + "max_output_tokens": 102400, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": false, + "supports_vision": true, + "supports_web_search": false }, "openrouter/mistralai/mistral-small-3.2-24b-instruct": { "input_cost_per_token": 9.375e-08, "litellm_provider": "openrouter", - "max_tokens": 128000, + "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 2.5e-07, "supports_tool_choice": true, - "max_input_tokens": 128000, - "max_output_tokens": 128000, - "source": "https://openrouter.ai/api/v1/models" + "max_input_tokens": 256000, + "max_output_tokens": 16384, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_vision": true, + "supports_web_search": false }, "openrouter/mistralai/mixtral-8x22b-instruct": { + "cache_read_input_token_cost": 2e-07, "input_cost_per_token": 2e-06, "litellm_provider": "openrouter", - "max_tokens": 65536, + "max_tokens": 52428, "mode": "chat", "output_cost_per_token": 6e-06, "supports_tool_choice": true, "max_input_tokens": 65536, - "max_output_tokens": 65536 + "max_output_tokens": 52428, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_vision": false, + "supports_web_search": false }, "openrouter/moonshotai/kimi-k2.5": { "cache_read_input_token_cost": 7e-08, "input_cost_per_token": 4.5e-07, "litellm_provider": "openrouter", "max_input_tokens": 262144, - "max_output_tokens": 262144, - "max_tokens": 262144, + "max_output_tokens": 235929, + "max_tokens": 235929, "mode": "chat", "output_cost_per_token": 2.25e-06, - "source": "https://openrouter.ai/moonshotai/kimi-k2.5", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, "supports_tool_choice": true, "supports_video_input": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/nvidia/nemotron-3.5-lightning": { + "cache_read_input_token_cost": 4e-08, "input_cost_per_token": 8e-08, "litellm_provider": "openrouter", "max_input_tokens": 262144, + "max_output_tokens": 131072, + "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 2e-07, - "source": "https://openrouter.ai/nvidia/nemotron-3.5-lightning", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, "supports_reasoning": true, - "supports_tool_choice": true + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false }, "openrouter/openai/gpt-3.5-turbo": { "input_cost_per_token": 5e-07, @@ -41575,7 +41834,15 @@ "supports_tool_choice": true, "max_input_tokens": 16385, "max_output_tokens": 4096, - "source": "https://openrouter.ai/openai/gpt-3.5-turbo" + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_vision": false, + "supports_web_search": false }, "openrouter/openai/gpt-3.5-turbo-16k": { "input_cost_per_token": 3e-06, @@ -41585,7 +41852,16 @@ "output_cost_per_token": 4e-06, "supports_tool_choice": true, "max_input_tokens": 16385, - "max_output_tokens": 4096 + "max_output_tokens": 4096, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_vision": false, + "supports_web_search": false }, "openrouter/openai/gpt-4": { "input_cost_per_token": 3e-05, @@ -41595,7 +41871,16 @@ "output_cost_per_token": 6e-05, "supports_tool_choice": true, "max_input_tokens": 8191, - "max_output_tokens": 4096 + "max_output_tokens": 4096, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_vision": false, + "supports_web_search": false }, "openrouter/openai/gpt-4.1": { "cache_read_input_token_cost": 5e-07, @@ -41606,13 +41891,18 @@ "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 8e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_pdf_input": true, "supports_prompt_caching": true, + "supports_reasoning": false, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/openai/gpt-4.1-mini": { "cache_read_input_token_cost": 1e-07, @@ -41623,13 +41913,18 @@ "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 1.6e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_pdf_input": true, "supports_prompt_caching": true, + "supports_reasoning": false, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/openai/gpt-4.1-nano": { "cache_read_input_token_cost": 2.5e-08, @@ -41640,13 +41935,18 @@ "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 4e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_pdf_input": true, "supports_prompt_caching": true, + "supports_reasoning": false, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/openai/gpt-4o": { "input_cost_per_token": 2.5e-06, @@ -41662,7 +41962,12 @@ "supports_vision": true, "cache_read_input_token_cost": 1.25e-06, "supports_prompt_caching": true, - "source": "https://openrouter.ai/openai/gpt-4o" + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_pdf_input": true, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_web_search": true }, "openrouter/openai/gpt-4o-2024-05-13": { "input_cost_per_token": 5e-06, @@ -41672,10 +41977,17 @@ "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1.5e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": true }, "openrouter/openai/gpt-5-chat": { "cache_read_input_token_cost": 1.25e-07, @@ -41719,11 +42031,12 @@ "cache_read_input_token_cost": 1.75e-07, "input_cost_per_token": 1.75e-06, "litellm_provider": "openrouter", - "max_input_tokens": 272000, + "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1.4e-05, + "source": "https://openrouter.ai/api/v1/models", "supported_modalities": [ "text", "image" @@ -41731,18 +42044,26 @@ "supported_output_modalities": [ "text" ], + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, "supports_reasoning": true, - "supports_tool_choice": true + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false }, "openrouter/openai/gpt-5": { "cache_read_input_token_cost": 1.25e-07, "input_cost_per_token": 1.25e-06, "litellm_provider": "openrouter", - "max_input_tokens": 272000, + "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1e-05, + "source": "https://openrouter.ai/api/v1/models", "supported_modalities": [ "text", "image" @@ -41750,18 +42071,26 @@ "supported_output_modalities": [ "text" ], + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, "supports_reasoning": true, - "supports_tool_choice": true + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false }, "openrouter/openai/gpt-5-mini": { "cache_read_input_token_cost": 2.5e-08, "input_cost_per_token": 2.5e-07, "litellm_provider": "openrouter", - "max_input_tokens": 272000, + "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 2e-06, + "source": "https://openrouter.ai/api/v1/models", "supported_modalities": [ "text", "image" @@ -41769,18 +42098,26 @@ "supported_output_modalities": [ "text" ], + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, "supports_reasoning": true, - "supports_tool_choice": true + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false }, "openrouter/openai/gpt-5-nano": { "cache_read_input_token_cost": 5e-09, "input_cost_per_token": 5e-08, "litellm_provider": "openrouter", - "max_input_tokens": 272000, + "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 4e-07, + "source": "https://openrouter.ai/api/v1/models", "supported_modalities": [ "text", "image" @@ -41788,8 +42125,15 @@ "supported_output_modalities": [ "text" ], + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, "supports_reasoning": true, - "supports_tool_choice": true + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false }, "openrouter/openai/gpt-5.1-codex-max": { "cache_read_input_token_cost": 1.25e-07, @@ -41800,7 +42144,7 @@ "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1e-05, - "source": "https://openrouter.ai/openai/gpt-5.1-codex-max", + "source": "https://openrouter.ai/api/v1/models", "supported_modalities": [ "text", "image" @@ -41808,27 +42152,36 @@ "supported_output_modalities": [ "text" ], + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_prompt_caching": true, "supports_reasoning": true, + "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/openai/gpt-5.2": { "input_cost_per_image": 0, "cache_read_input_token_cost": 1.75e-07, "input_cost_per_token": 1.75e-06, "litellm_provider": "openrouter", - "max_input_tokens": 272000, + "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1.4e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, + "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/openai/gpt-5.2-chat": { "input_cost_per_image": 0, @@ -41836,29 +42189,40 @@ "input_cost_per_token": 1.75e-06, "litellm_provider": "openrouter", "max_input_tokens": 128000, - "max_output_tokens": 16384, - "max_tokens": 16384, + "max_output_tokens": 32000, + "max_tokens": 32000, "mode": "chat", "output_cost_per_token": 1.4e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": true, "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/openai/gpt-5.2-pro": { "input_cost_per_image": 0, "input_cost_per_token": 2.1e-05, "litellm_provider": "openrouter", - "max_input_tokens": 272000, + "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 0.000168, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": true, "supports_prompt_caching": false, "supports_reasoning": true, + "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/openai/gpt-5.6-sol": { "cache_creation_input_token_cost": 2.5e-06, @@ -41883,7 +42247,7 @@ "xhigh", "max" ], - "source": "https://openrouter.ai/openai/gpt-5.6-sol", + "source": "https://openrouter.ai/api/v1/models", "supported_modalities": [ "text", "image" @@ -41891,19 +42255,22 @@ "supported_output_modalities": [ "text" ], + "supports_audio_input": false, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/openai/gpt-5.6-sol-pro": { "input_cost_per_token": 2e-06, "output_cost_per_token": 1e-05, "cache_read_input_token_cost": 2e-07, "cache_creation_input_token_cost": 2.5e-06, + "cache_creation_input_token_cost_above_272k_tokens": 5e-06, "input_cost_per_token_above_272k_tokens": 4e-06, "output_cost_per_token_above_272k_tokens": 1.5e-05, "cache_read_input_token_cost_above_272k_tokens": 4e-07, @@ -41912,44 +42279,58 @@ "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "source": "https://openrouter.ai/openai/gpt-5.6-sol-pro", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, "supports_pdf_input": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/openai/gpt-oss-120b": { - "input_cost_per_token": 3.7e-08, + "cache_read_input_token_cost": 7.5e-08, + "input_cost_per_token": 1.5e-07, "litellm_provider": "openrouter", "max_input_tokens": 131072, - "max_output_tokens": 32768, - "max_tokens": 32768, + "max_output_tokens": 117964, + "max_tokens": 117964, "mode": "chat", - "output_cost_per_token": 1.7e-07, - "source": "https://openrouter.ai/openai/gpt-oss-120b", + "output_cost_per_token": 6e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_reasoning": true, "supports_response_schema": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false }, "openrouter/openai/gpt-oss-20b": { + "cache_read_input_token_cost": 3e-08, "input_cost_per_token": 3e-08, "litellm_provider": "openrouter", "max_input_tokens": 131072, - "max_output_tokens": 32768, - "max_tokens": 32768, + "max_output_tokens": 117964, + "max_tokens": 117964, "mode": "chat", "output_cost_per_token": 1.3e-07, - "source": "https://openrouter.ai/openai/gpt-oss-20b", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false }, "openrouter/openai/o1": { "cache_read_input_token_cost": 7.5e-06, @@ -41960,13 +42341,18 @@ "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 6e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_pdf_input": true, "supports_prompt_caching": true, + "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/openai/o3-mini": { "input_cost_per_token": 1.1e-06, @@ -41983,7 +42369,11 @@ "supports_vision": false, "cache_read_input_token_cost": 5.5e-07, "supports_prompt_caching": true, - "source": "https://openrouter.ai/openai/o3-mini" + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_web_search": false }, "openrouter/openai/o3-mini-high": { "input_cost_per_token": 1.1e-06, @@ -42000,17 +42390,30 @@ "supports_vision": false, "cache_read_input_token_cost": 5.5e-07, "supports_prompt_caching": true, - "source": "https://openrouter.ai/openai/o3-mini-high" + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_web_search": false }, "openrouter/qwen/qwen-2.5-coder-32b-instruct": { "input_cost_per_token": 6.6e-07, "litellm_provider": "openrouter", - "max_input_tokens": 33792, - "max_output_tokens": 33792, - "max_tokens": 33792, + "max_input_tokens": 32768, + "max_output_tokens": 29491, + "max_tokens": 29491, "mode": "chat", "output_cost_per_token": 1e-06, - "supports_tool_choice": true + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": false, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false }, "openrouter/qwen/qwen-vl-plus": { "input_cost_per_token": 2.1e-07, @@ -42024,56 +42427,89 @@ "supports_vision": true }, "openrouter/qwen/qwen3-coder": { + "cache_read_input_token_cost": 1e-07, "input_cost_per_token": 3e-07, "litellm_provider": "openrouter", - "max_input_tokens": 262100, - "max_output_tokens": 262100, - "max_tokens": 262100, + "max_input_tokens": 262144, + "max_output_tokens": 65536, + "max_tokens": 65536, "mode": "chat", "output_cost_per_token": 1e-06, - "source": "https://openrouter.ai/qwen/qwen3-coder", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_tool_choice": true, - "supports_function_calling": true + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_vision": false, + "supports_web_search": false }, "openrouter/qwen/qwen3-coder-plus": { + "cache_creation_input_token_cost": 8.125e-07, + "cache_read_input_token_cost": 1.3e-07, "input_cost_per_token": 6.5e-07, + "input_cost_per_token_above_128k_tokens": 1.95e-06, "litellm_provider": "openrouter", - "max_input_tokens": 997952, + "max_input_tokens": 1000000, "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", "output_cost_per_token": 3.25e-06, - "source": "https://openrouter.ai/qwen/qwen3-coder-plus", + "output_cost_per_token_above_128k_tokens": 9.75e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, - "supports_reasoning": true, - "supports_tool_choice": true + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false }, "openrouter/qwen/qwen3-235b-a22b-2507": { + "cache_read_input_token_cost": 1.75e-08, "input_cost_per_token": 8.75e-08, "litellm_provider": "openrouter", "max_input_tokens": 262144, - "max_output_tokens": 262144, - "max_tokens": 262144, + "max_output_tokens": 235929, + "max_tokens": 235929, "mode": "chat", "output_cost_per_token": 3.5e-07, - "source": "https://openrouter.ai/qwen/qwen3-235b-a22b-2507", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, - "supports_tool_choice": true + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false }, "openrouter/qwen/qwen3-235b-a22b-thinking-2507": { "input_cost_per_token": 2.3e-07, "litellm_provider": "openrouter", - "max_input_tokens": 262144, - "max_output_tokens": 262144, - "max_tokens": 262144, + "max_input_tokens": 131072, + "max_output_tokens": 117964, + "max_tokens": 117964, "mode": "chat", "output_cost_per_token": 2.3e-06, - "source": "https://openrouter.ai/qwen/qwen3-235b-a22b-thinking-2507", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_reasoning": true, - "supports_tool_choice": true + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false }, "openrouter/qwen/qwen3.6-plus": { + "cache_creation_input_token_cost": 4.0625e-07, "input_cost_per_token": 3.25e-07, "litellm_provider": "openrouter", "max_input_tokens": 1000000, @@ -42081,11 +42517,16 @@ "max_tokens": 65536, "mode": "chat", "output_cost_per_token": 1.95e-06, - "source": "https://openrouter.ai/qwen/qwen3.6-plus", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_reasoning": true, + "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/qwen/qwen3.5-35b-a3b": { "input_cost_per_token": 1.625e-07, @@ -42095,12 +42536,17 @@ "max_tokens": 65536, "mode": "chat", "output_cost_per_token": 1.3e-06, - "source": "https://openrouter.ai/qwen/qwen3.5-35b-a3b", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true, "supports_vision": true, - "cache_read_input_token_cost": 1.5625e-07 + "cache_read_input_token_cost": 1.5625e-07, + "supports_audio_input": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_response_schema": true, + "supports_web_search": false }, "openrouter/qwen/qwen3.5-27b": { "input_cost_per_token": 1.95e-07, @@ -42110,11 +42556,16 @@ "max_tokens": 65536, "mode": "chat", "output_cost_per_token": 1.56e-06, - "source": "https://openrouter.ai/qwen/qwen3.5-27b", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_reasoning": true, + "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/qwen/qwen3.5-122b-a10b": { "input_cost_per_token": 2.6e-07, @@ -42124,11 +42575,16 @@ "max_tokens": 65536, "mode": "chat", "output_cost_per_token": 2.08e-06, - "source": "https://openrouter.ai/qwen/qwen3.5-122b-a10b", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_reasoning": true, + "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/qwen/qwen3.5-flash-02-23": { "input_cost_per_token": 6.5e-08, @@ -42138,11 +42594,16 @@ "max_tokens": 65536, "mode": "chat", "output_cost_per_token": 2.6e-07, - "source": "https://openrouter.ai/qwen/qwen3.5-flash-02-23", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_reasoning": true, + "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/qwen/qwen3.5-plus-02-15": { "input_cost_per_token": 2.6e-07, @@ -42154,25 +42615,36 @@ "mode": "chat", "output_cost_per_token": 1.56e-06, "output_cost_per_token_above_256k_tokens": 3e-06, - "source": "https://openrouter.ai/qwen/qwen3.5-plus-02-15", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_reasoning": true, + "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/qwen/qwen3.5-397b-a17b": { + "cache_read_input_token_cost": 2.25e-07, "input_cost_per_token": 5.5e-07, "litellm_provider": "openrouter", "max_input_tokens": 262144, - "max_output_tokens": 65536, - "max_tokens": 65536, + "max_output_tokens": 235929, + "max_tokens": 235929, "mode": "chat", "output_cost_per_token": 3.5e-06, - "source": "https://openrouter.ai/qwen/qwen3.5-397b-a17b", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, "supports_reasoning": true, + "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/switchpoint/router": { "input_cost_per_token": 8.5e-07, @@ -42186,14 +42658,23 @@ "supports_tool_choice": true }, "openrouter/undi95/remm-slerp-l2-13b": { - "input_cost_per_token": 4.5e-07, + "input_cost_per_token": 3.5e-07, "litellm_provider": "openrouter", - "max_tokens": 4096, + "max_tokens": 5529, "mode": "chat", "output_cost_per_token": 6.5e-07, - "supports_tool_choice": true, + "supports_tool_choice": false, "max_input_tokens": 6144, - "max_output_tokens": 4096 + "max_output_tokens": 5529, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_vision": false, + "supports_web_search": false }, "openrouter/x-ai/grok-4": { "input_cost_per_token": 3e-06, @@ -42212,17 +42693,22 @@ "openrouter/z-ai/glm-4.6": { "input_cost_per_token": 4.3e-07, "litellm_provider": "openrouter", - "max_input_tokens": 202800, - "max_output_tokens": 131000, - "max_tokens": 131000, + "max_input_tokens": 204800, + "max_output_tokens": 16384, + "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1.75e-06, - "source": "https://openrouter.ai/z-ai/glm-4.6", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_tool_choice": true, - "cache_read_input_token_cost": 8e-08 + "cache_read_input_token_cost": 8e-08, + "supports_audio_input": false, + "supports_pdf_input": false, + "supports_response_schema": true, + "supports_vision": false, + "supports_web_search": false }, "openrouter/z-ai/glm-4.6:exacto": { "input_cost_per_token": 4.5e-07, @@ -42260,16 +42746,20 @@ "cache_creation_input_token_cost": 0.0, "cache_read_input_token_cost": 3.6e-09, "litellm_provider": "openrouter", - "max_input_tokens": 1048576, - "max_output_tokens": 16384, - "max_tokens": 16384, + "max_input_tokens": 1050000, + "max_output_tokens": 131072, + "max_tokens": 131072, "mode": "chat", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_vision": false, "supports_response_schema": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/xiaomi/mimo-v2.5": { "input_cost_per_token": 1.4e-07, @@ -42277,18 +42767,21 @@ "cache_creation_input_token_cost": 0.0, "cache_read_input_token_cost": 2.8e-09, "litellm_provider": "openrouter", - "max_input_tokens": 1048576, + "max_input_tokens": 1050000, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, "supports_vision": true, "supports_audio_input": true, + "supports_pdf_input": false, "supports_video_input": true, "supports_response_schema": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/z-ai/glm-4.7": { "input_cost_per_token": 4e-07, @@ -42296,45 +42789,62 @@ "cache_creation_input_token_cost": 0.0, "cache_read_input_token_cost": 8e-08, "litellm_provider": "openrouter", - "max_input_tokens": 202752, - "max_output_tokens": 64000, - "max_tokens": 64000, + "max_input_tokens": 204800, + "max_output_tokens": 131072, + "max_tokens": 131072, "mode": "chat", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, - "supports_vision": true, - "supports_prompt_caching": false, - "supports_assistant_prefill": true + "supports_vision": false, + "supports_prompt_caching": true, + "supports_assistant_prefill": true, + "supports_audio_input": false, + "supports_pdf_input": false, + "supports_response_schema": true, + "supports_web_search": false }, "openrouter/z-ai/glm-4.7-flash": { - "input_cost_per_token": 6e-08, + "input_cost_per_token": 6.05e-08, "output_cost_per_token": 4e-07, "cache_creation_input_token_cost": 0.0, "cache_read_input_token_cost": 1e-08, "litellm_provider": "openrouter", "max_input_tokens": 200000, - "max_output_tokens": 32000, - "max_tokens": 32000, + "max_output_tokens": 117964, + "max_tokens": 117964, "mode": "chat", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, - "supports_vision": true, - "supports_prompt_caching": false + "supports_vision": false, + "supports_prompt_caching": false, + "supports_response_schema": true, + "supports_web_search": false }, "openrouter/z-ai/glm-5": { + "cache_read_input_token_cost": 1.2e-07, "input_cost_per_token": 6e-07, "litellm_provider": "openrouter", - "max_input_tokens": 202752, + "max_input_tokens": 204800, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1.92e-06, - "source": "https://openrouter.ai/z-ai/glm-5", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, "supports_reasoning": true, - "supports_tool_choice": true + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false }, "openrouter/z-ai/glm-5.1": { "input_cost_per_token": 9.66e-07, @@ -42342,15 +42852,20 @@ "cache_read_input_token_cost": 1.794e-07, "cache_creation_input_token_cost": 0.0, "litellm_provider": "openrouter", - "max_input_tokens": 202752, - "max_output_tokens": 65535, - "max_tokens": 65535, + "max_input_tokens": 204800, + "max_output_tokens": 128000, + "max_tokens": 128000, "mode": "chat", - "source": "https://openrouter.ai/z-ai/glm-5.1", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_prompt_caching": true, "supports_reasoning": true, - "supports_tool_choice": true + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false }, "openrouter/minimax/minimax-m2.1": { "input_cost_per_token": 3e-07, @@ -42358,33 +42873,42 @@ "cache_creation_input_token_cost": 0.0, "cache_read_input_token_cost": 3e-08, "litellm_provider": "openrouter", - "max_input_tokens": 204000, - "max_output_tokens": 64000, - "max_tokens": 64000, + "max_input_tokens": 204800, + "max_output_tokens": 131072, + "max_tokens": 131072, "mode": "chat", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, - "supports_vision": true, - "supports_prompt_caching": false, - "supports_computer_use": false + "supports_vision": false, + "supports_prompt_caching": true, + "supports_computer_use": false, + "supports_pdf_input": false, + "supports_response_schema": true, + "supports_web_search": false }, "openrouter/minimax/minimax-m2.5": { "input_cost_per_token": 2.7e-07, "output_cost_per_token": 1.08e-06, "cache_read_input_token_cost": 2.7e-08, "litellm_provider": "openrouter", - "max_input_tokens": 196608, - "max_output_tokens": 65536, - "max_tokens": 65536, + "max_input_tokens": 204800, + "max_output_tokens": 128000, + "max_tokens": 128000, "mode": "chat", - "source": "https://openrouter.ai/minimax/minimax-m2.5", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, "supports_vision": false, "supports_prompt_caching": true, - "supports_computer_use": false + "supports_computer_use": false, + "supports_pdf_input": false, + "supports_response_schema": true, + "supports_web_search": false }, "openrouter/openrouter/auto": { "input_cost_per_token": 0, @@ -42423,18 +42947,24 @@ "mode": "chat" }, "openrouter/stealth/union-alpha": { - "input_cost_per_token": 0, - "output_cost_per_token": 0, + "deprecation_date": "2098-12-31", + "input_cost_per_token": 0.0, + "output_cost_per_token": 0.0, "litellm_provider": "openrouter", "max_input_tokens": 262144, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", - "source": "https://openrouter.ai/stealth/union-alpha", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, "supports_tool_choice": true, "supports_response_schema": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "ovhcloud/DeepSeek-R1-Distill-Llama-70B": { "input_cost_per_token": 6.7e-07, @@ -64526,7 +65056,7 @@ "supports_sampling_params": false, "supports_adaptive_thinking": true, "thinking_always_on": true, - "source": "https://openrouter.ai/anthropic/claude-fable-5", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, @@ -64537,7 +65067,9 @@ "cache_read_input_token_cost": 1e-06, "supports_prompt_caching": true, "cache_creation_input_token_cost": 1.25e-05, - "prompt_cache_min_tokens": 512 + "cache_creation_input_token_cost_above_1hr": 2e-05, + "prompt_cache_min_tokens": 512, + "supports_web_search": false }, "openrouter/anthropic/claude-fable-5.1": { "input_cost_per_token": 1e-05, @@ -64550,7 +65082,7 @@ "supports_sampling_params": false, "supports_adaptive_thinking": true, "thinking_always_on": true, - "source": "https://openrouter.ai/anthropic/claude-fable-5.1", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": false, "supports_reasoning": true, @@ -64561,7 +65093,9 @@ "cache_read_input_token_cost": 2.5e-07, "supports_prompt_caching": true, "cache_creation_input_token_cost": 1.25e-05, - "prompt_cache_min_tokens": 512 + "cache_creation_input_token_cost_above_1hr": 2e-05, + "prompt_cache_min_tokens": 512, + "supports_web_search": false }, "openrouter/anthropic/claude-opus-4.8": { "input_cost_per_token": 5e-06, @@ -64573,7 +65107,7 @@ "mode": "chat", "supports_sampling_params": false, "supports_adaptive_thinking": true, - "source": "https://openrouter.ai/anthropic/claude-opus-4.8", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, @@ -64583,7 +65117,9 @@ "supports_audio_input": false, "cache_read_input_token_cost": 5e-07, "supports_prompt_caching": true, - "cache_creation_input_token_cost": 6.25e-06 + "cache_creation_input_token_cost": 6.25e-06, + "cache_creation_input_token_cost_above_1hr": 1e-05, + "supports_web_search": false }, "openrouter/anthropic/claude-sonnet-5": { "input_cost_per_token": 2e-06, @@ -64595,7 +65131,7 @@ "mode": "chat", "supports_sampling_params": false, "supports_adaptive_thinking": true, - "source": "https://openrouter.ai/anthropic/claude-sonnet-5", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, @@ -64605,9 +65141,13 @@ "supports_audio_input": false, "cache_read_input_token_cost": 2e-07, "supports_prompt_caching": true, - "cache_creation_input_token_cost": 2.5e-06 + "cache_creation_input_token_cost": 2.5e-06, + "cache_creation_input_token_cost_above_1hr": 4e-06, + "supports_web_search": false }, "openrouter/google/gemini-2.5-flash-lite": { + "cache_creation_input_token_cost": 8.33333333333333e-08, + "cache_read_input_audio_token_cost": 3e-08, "input_cost_per_token": 1e-07, "output_cost_per_token": 4e-07, "litellm_provider": "openrouter", @@ -64615,7 +65155,7 @@ "max_output_tokens": 65535, "max_tokens": 65535, "mode": "chat", - "source": "https://openrouter.ai/google/gemini-2.5-flash-lite", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, @@ -64624,17 +65164,21 @@ "supports_pdf_input": true, "supports_audio_input": true, "cache_read_input_token_cost": 1e-08, - "supports_prompt_caching": true + "input_cost_per_audio_token": 3e-07, + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/google/gemini-3.5-flash": { + "cache_creation_input_token_cost": 8.33333333333333e-08, + "cache_read_input_audio_token_cost": 3e-07, "input_cost_per_token": 1.5e-06, "output_cost_per_token": 9e-06, "litellm_provider": "openrouter", "max_input_tokens": 1048576, - "max_output_tokens": 65535, - "max_tokens": 65535, + "max_output_tokens": 65536, + "max_tokens": 65536, "mode": "chat", - "source": "https://openrouter.ai/google/gemini-3.5-flash", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, @@ -64643,9 +65187,13 @@ "supports_pdf_input": true, "supports_audio_input": true, "cache_read_input_token_cost": 1.5e-07, - "supports_prompt_caching": true + "input_cost_per_audio_token": 3e-06, + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/google/gemini-3.5-flash-lite": { + "cache_creation_input_token_cost": 8.33333333333333e-08, + "cache_read_input_audio_token_cost": 3e-08, "input_cost_per_token": 3e-07, "output_cost_per_token": 2.5e-06, "litellm_provider": "openrouter", @@ -64653,7 +65201,7 @@ "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", - "source": "https://openrouter.ai/google/gemini-3.5-flash-lite", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, @@ -64662,9 +65210,13 @@ "supports_pdf_input": true, "supports_audio_input": true, "cache_read_input_token_cost": 3e-08, - "supports_prompt_caching": true + "input_cost_per_audio_token": 3e-07, + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/google/gemini-3.6-flash": { + "cache_creation_input_token_cost": 4.16666666666667e-08, + "cache_read_input_audio_token_cost": 7.5e-08, "input_cost_per_token": 7.5e-07, "output_cost_per_token": 3.75e-06, "litellm_provider": "openrouter", @@ -64672,7 +65224,7 @@ "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", - "source": "https://openrouter.ai/google/gemini-3.6-flash", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, @@ -64681,9 +65233,13 @@ "supports_pdf_input": true, "supports_audio_input": true, "cache_read_input_token_cost": 7.5e-08, - "supports_prompt_caching": true + "input_cost_per_audio_token": 7.5e-07, + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/google/gemini-3.7-flash": { + "cache_creation_input_token_cost": 4.16666666666667e-08, + "cache_read_input_audio_token_cost": 7.5e-08, "input_cost_per_token": 7.5e-07, "output_cost_per_token": 3.75e-06, "litellm_provider": "openrouter", @@ -64691,7 +65247,7 @@ "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", - "source": "https://openrouter.ai/google/gemini-3.7-flash", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, @@ -64700,9 +65256,13 @@ "supports_pdf_input": true, "supports_audio_input": true, "cache_read_input_token_cost": 7.5e-08, - "supports_prompt_caching": true + "input_cost_per_audio_token": 7.5e-07, + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/google/gemini-3.8-flash": { + "cache_creation_input_token_cost": 4.16666666666667e-08, + "cache_read_input_audio_token_cost": 7.5e-08, "input_cost_per_token": 7.5e-07, "output_cost_per_token": 3.75e-06, "litellm_provider": "openrouter", @@ -64710,7 +65270,7 @@ "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", - "source": "https://openrouter.ai/google/gemini-3.8-flash", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, @@ -64719,7 +65279,9 @@ "supports_pdf_input": true, "supports_audio_input": true, "cache_read_input_token_cost": 7.5e-08, - "supports_prompt_caching": true + "input_cost_per_audio_token": 7.5e-07, + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/openai/gpt-4o-mini": { "input_cost_per_token": 1.5e-07, @@ -64729,7 +65291,7 @@ "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", - "source": "https://openrouter.ai/openai/gpt-4o-mini", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": false, @@ -64738,17 +65300,18 @@ "supports_pdf_input": true, "supports_audio_input": false, "cache_read_input_token_cost": 7.5e-08, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": true }, "openrouter/openai/gpt-5.1": { "input_cost_per_token": 1.25e-06, "output_cost_per_token": 1e-05, "litellm_provider": "openrouter", - "max_input_tokens": 272000, + "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "source": "https://openrouter.ai/openai/gpt-5.1", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, @@ -64757,17 +65320,18 @@ "supports_pdf_input": true, "supports_audio_input": false, "cache_read_input_token_cost": 1.25e-07, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/openai/gpt-5.3-codex": { "input_cost_per_token": 1.75e-06, "output_cost_per_token": 1.4e-05, "litellm_provider": "openrouter", - "max_input_tokens": 272000, + "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "source": "https://openrouter.ai/openai/gpt-5.3-codex", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, @@ -64776,7 +65340,8 @@ "supports_pdf_input": true, "supports_audio_input": false, "cache_read_input_token_cost": 1.75e-07, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/openai/gpt-5.4": { "input_cost_per_token": 2.5e-06, @@ -64786,7 +65351,7 @@ "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "source": "https://openrouter.ai/openai/gpt-5.4", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, @@ -64795,17 +65360,21 @@ "supports_pdf_input": true, "supports_audio_input": false, "cache_read_input_token_cost": 2.5e-07, - "supports_prompt_caching": true + "cache_read_input_token_cost_above_272k_tokens": 5e-07, + "input_cost_per_token_above_272k_tokens": 5e-06, + "output_cost_per_token_above_272k_tokens": 2.25e-05, + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/openai/gpt-5.4-mini": { "input_cost_per_token": 7.5e-07, "output_cost_per_token": 4.5e-06, "litellm_provider": "openrouter", - "max_input_tokens": 272000, + "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "source": "https://openrouter.ai/openai/gpt-5.4-mini", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, @@ -64814,17 +65383,18 @@ "supports_pdf_input": true, "supports_audio_input": false, "cache_read_input_token_cost": 7.5e-08, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/openai/gpt-5.4-nano": { "input_cost_per_token": 2e-07, "output_cost_per_token": 1.25e-06, "litellm_provider": "openrouter", - "max_input_tokens": 272000, + "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "source": "https://openrouter.ai/openai/gpt-5.4-nano", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, @@ -64833,7 +65403,8 @@ "supports_pdf_input": true, "supports_audio_input": false, "cache_read_input_token_cost": 2e-08, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/openai/gpt-5.5": { "input_cost_per_token": 5e-06, @@ -64843,7 +65414,7 @@ "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "source": "https://openrouter.ai/openai/gpt-5.5", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, @@ -64852,17 +65423,23 @@ "supports_pdf_input": true, "supports_audio_input": false, "cache_read_input_token_cost": 5e-07, - "supports_prompt_caching": true + "cache_read_input_token_cost_above_272k_tokens": 1e-06, + "input_cost_per_token_above_272k_tokens": 1e-05, + "output_cost_per_token_above_272k_tokens": 4.5e-05, + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/openai/gpt-5.6-luna": { + "cache_creation_input_token_cost": 2.5e-07, + "cache_creation_input_token_cost_above_272k_tokens": 5e-07, "input_cost_per_token": 2e-07, "output_cost_per_token": 1.2e-06, "litellm_provider": "openrouter", - "max_input_tokens": 922000, + "max_input_tokens": 1050000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "source": "https://openrouter.ai/openai/gpt-5.6-luna", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, @@ -64871,13 +65448,18 @@ "supports_pdf_input": true, "supports_audio_input": false, "cache_read_input_token_cost": 2e-08, - "supports_prompt_caching": true + "cache_read_input_token_cost_above_272k_tokens": 4e-08, + "input_cost_per_token_above_272k_tokens": 4e-07, + "output_cost_per_token_above_272k_tokens": 1.8e-06, + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/openai/gpt-5.6-luna-pro": { "input_cost_per_token": 2e-07, "output_cost_per_token": 1.2e-06, "cache_read_input_token_cost": 2e-08, "cache_creation_input_token_cost": 2.5e-07, + "cache_creation_input_token_cost_above_272k_tokens": 5e-07, "input_cost_per_token_above_272k_tokens": 4e-07, "output_cost_per_token_above_272k_tokens": 1.8e-06, "cache_read_input_token_cost_above_272k_tokens": 4e-08, @@ -64886,24 +65468,28 @@ "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "source": "https://openrouter.ai/openai/gpt-5.6-luna-pro", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, "supports_pdf_input": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/openai/gpt-5.6-terra": { + "cache_creation_input_token_cost": 2.5e-06, + "cache_creation_input_token_cost_above_272k_tokens": 5e-06, "input_cost_per_token": 2e-06, "output_cost_per_token": 1.2e-05, "litellm_provider": "openrouter", - "max_input_tokens": 922000, + "max_input_tokens": 1050000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "source": "https://openrouter.ai/openai/gpt-5.6-terra", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, @@ -64912,13 +65498,18 @@ "supports_pdf_input": true, "supports_audio_input": false, "cache_read_input_token_cost": 2e-07, - "supports_prompt_caching": true + "cache_read_input_token_cost_above_272k_tokens": 4e-07, + "input_cost_per_token_above_272k_tokens": 4e-06, + "output_cost_per_token_above_272k_tokens": 1.8e-05, + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/openai/gpt-5.6-terra-pro": { "input_cost_per_token": 2e-06, "output_cost_per_token": 1.2e-05, "cache_read_input_token_cost": 2e-07, "cache_creation_input_token_cost": 2.5e-06, + "cache_creation_input_token_cost_above_272k_tokens": 5e-06, "input_cost_per_token_above_272k_tokens": 4e-06, "output_cost_per_token_above_272k_tokens": 1.8e-05, "cache_read_input_token_cost_above_272k_tokens": 4e-07, @@ -64927,14 +65518,16 @@ "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "source": "https://openrouter.ai/openai/gpt-5.6-terra-pro", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, "supports_pdf_input": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/openai/o3": { "input_cost_per_token": 2e-06, @@ -64944,7 +65537,7 @@ "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", - "source": "https://openrouter.ai/openai/o3", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, @@ -64953,7 +65546,8 @@ "supports_pdf_input": true, "supports_audio_input": false, "cache_read_input_token_cost": 5e-07, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/openai/o4-mini": { "input_cost_per_token": 1.1e-06, @@ -64963,7 +65557,7 @@ "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", - "source": "https://openrouter.ai/openai/o4-mini", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, @@ -64972,17 +65566,18 @@ "supports_pdf_input": true, "supports_audio_input": false, "cache_read_input_token_cost": 2.75e-07, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/x-ai/grok-4.20": { "input_cost_per_token": 1.25e-06, "output_cost_per_token": 2.5e-06, "litellm_provider": "openrouter", - "max_input_tokens": 1000000, - "max_output_tokens": 1000000, - "max_tokens": 1000000, + "max_input_tokens": 2000000, + "max_output_tokens": 1800000, + "max_tokens": 1800000, "mode": "chat", - "source": "https://openrouter.ai/x-ai/grok-4.20", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, @@ -64991,17 +65586,21 @@ "supports_pdf_input": true, "supports_audio_input": false, "cache_read_input_token_cost": 2e-07, - "supports_prompt_caching": true + "cache_read_input_token_cost_above_200k_tokens": 4e-07, + "input_cost_per_token_above_200k_tokens": 2.5e-06, + "output_cost_per_token_above_200k_tokens": 5e-06, + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/x-ai/grok-4.20-multi-agent": { "input_cost_per_token": 1.25e-06, "output_cost_per_token": 2.5e-06, "litellm_provider": "openrouter", - "max_input_tokens": 1000000, - "max_output_tokens": 1000000, - "max_tokens": 1000000, + "max_input_tokens": 2000000, + "max_output_tokens": 1800000, + "max_tokens": 1800000, "mode": "chat", - "source": "https://openrouter.ai/x-ai/grok-4.20-multi-agent", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": false, "supports_tool_choice": false, "supports_reasoning": true, @@ -65010,17 +65609,21 @@ "supports_pdf_input": true, "supports_audio_input": false, "cache_read_input_token_cost": 2e-07, - "supports_prompt_caching": true + "cache_read_input_token_cost_above_200k_tokens": 4e-07, + "input_cost_per_token_above_200k_tokens": 2.5e-06, + "output_cost_per_token_above_200k_tokens": 5e-06, + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/x-ai/grok-4.3": { "input_cost_per_token": 1.25e-06, "output_cost_per_token": 2.5e-06, "litellm_provider": "openrouter", "max_input_tokens": 1000000, - "max_output_tokens": 1000000, - "max_tokens": 1000000, + "max_output_tokens": 900000, + "max_tokens": 900000, "mode": "chat", - "source": "https://openrouter.ai/x-ai/grok-4.3", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, @@ -65029,17 +65632,21 @@ "supports_pdf_input": true, "supports_audio_input": false, "cache_read_input_token_cost": 2e-07, - "supports_prompt_caching": true + "cache_read_input_token_cost_above_200k_tokens": 4e-07, + "input_cost_per_token_above_200k_tokens": 2.5e-06, + "output_cost_per_token_above_200k_tokens": 5e-06, + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/x-ai/grok-4.5": { "input_cost_per_token": 2e-06, "output_cost_per_token": 6e-06, "litellm_provider": "openrouter", "max_input_tokens": 500000, - "max_output_tokens": 500000, - "max_tokens": 500000, + "max_output_tokens": 450000, + "max_tokens": 450000, "mode": "chat", - "source": "https://openrouter.ai/x-ai/grok-4.5", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, @@ -65048,17 +65655,21 @@ "supports_pdf_input": true, "supports_audio_input": false, "cache_read_input_token_cost": 3e-07, - "supports_prompt_caching": true + "cache_read_input_token_cost_above_200k_tokens": 6e-07, + "input_cost_per_token_above_200k_tokens": 4e-06, + "output_cost_per_token_above_200k_tokens": 1.2e-05, + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/x-ai/grok-4.6": { "input_cost_per_token": 2e-06, "output_cost_per_token": 6e-06, "litellm_provider": "openrouter", "max_input_tokens": 500000, - "max_output_tokens": 500000, - "max_tokens": 500000, + "max_output_tokens": 450000, + "max_tokens": 450000, "mode": "chat", - "source": "https://openrouter.ai/x-ai/grok-4.6", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, @@ -65067,17 +65678,21 @@ "supports_pdf_input": true, "supports_audio_input": false, "cache_read_input_token_cost": 5e-07, - "supports_prompt_caching": true + "cache_read_input_token_cost_above_200k_tokens": 1e-06, + "input_cost_per_token_above_200k_tokens": 4e-06, + "output_cost_per_token_above_200k_tokens": 1.2e-05, + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/x-ai/grok-build-0.1": { "input_cost_per_token": 1e-06, "output_cost_per_token": 2e-06, "litellm_provider": "openrouter", "max_input_tokens": 256000, - "max_output_tokens": 256000, - "max_tokens": 256000, + "max_output_tokens": 230400, + "max_tokens": 230400, "mode": "chat", - "source": "https://openrouter.ai/x-ai/grok-build-0.1", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, @@ -65086,7 +65701,11 @@ "supports_pdf_input": true, "supports_audio_input": false, "cache_read_input_token_cost": 2e-07, - "supports_prompt_caching": true + "cache_read_input_token_cost_above_200k_tokens": 4e-07, + "input_cost_per_token_above_200k_tokens": 2e-06, + "output_cost_per_token_above_200k_tokens": 4e-06, + "supports_prompt_caching": true, + "supports_web_search": false }, "baseten/zai-org/GLM-5.3": { "cache_read_input_token_cost": 1.4e-07, @@ -65119,14 +65738,17 @@ "max_output_tokens": 512000, "max_tokens": 512000, "mode": "chat", - "source": "https://openrouter.ai/minimax/minimax-m3", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, "cache_read_input_token_cost": 6e-08, - "supports_prompt_caching": true + "supports_audio_input": false, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/qwen/qwen3.7-plus": { "input_cost_per_token": 3.2e-07, @@ -65136,7 +65758,7 @@ "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3.7-plus", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, @@ -65144,7 +65766,10 @@ "supports_vision": true, "cache_read_input_token_cost": 6.4e-08, "supports_prompt_caching": true, - "cache_creation_input_token_cost": 4e-07 + "cache_creation_input_token_cost": 4e-07, + "supports_audio_input": false, + "supports_pdf_input": false, + "supports_web_search": false }, "openrouter/openai/gpt-6-astra": { "input_cost_per_token": 1e-05, @@ -65160,20 +65785,23 @@ "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "source": "https://openrouter.ai/openai/gpt-6-astra", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, "supports_pdf_input": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/openai/gpt-6-astra-pro": { "input_cost_per_token": 1e-05, "output_cost_per_token": 5e-05, "cache_read_input_token_cost": 1e-06, "cache_creation_input_token_cost": 1.25e-05, + "cache_creation_input_token_cost_above_272k_tokens": 2.5e-05, "input_cost_per_token_above_272k_tokens": 2e-05, "output_cost_per_token_above_272k_tokens": 7.5e-05, "cache_read_input_token_cost_above_272k_tokens": 2e-06, @@ -65182,14 +65810,16 @@ "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "source": "https://openrouter.ai/openai/gpt-6-astra-pro", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, "supports_pdf_input": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/qwen/qwen3.8-flash": { "input_cost_per_token": 1.5e-07, @@ -65201,13 +65831,16 @@ "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3.8-flash", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/z-ai/glm-5.3-flash": { "input_cost_per_token": 9e-08, @@ -65218,48 +65851,57 @@ "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", - "source": "https://openrouter.ai/z-ai/glm-5.3-flash", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/deepseek/deepseek-v4-flash-vision-exp": { - "input_cost_per_token": 2.2e-07, - "output_cost_per_token": 6.6e-07, - "cache_read_input_token_cost": 7e-09, + "input_cost_per_token": 2.156e-07, + "output_cost_per_token": 6.468e-07, + "cache_read_input_token_cost": 6.86e-09, "litellm_provider": "openrouter", "max_input_tokens": 1048576, - "max_output_tokens": 384000, - "max_tokens": 384000, + "max_output_tokens": 943718, + "max_tokens": 943718, "mode": "chat", - "source": "https://openrouter.ai/deepseek/deepseek-v4-flash-vision-exp", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/z-ai/glm-5.3": { "input_cost_per_token": 1.4e-06, "output_cost_per_token": 4.4e-06, - "cache_read_input_token_cost": 1.4e-07, + "cache_read_input_token_cost": 2.6e-07, "litellm_provider": "openrouter", "max_input_tokens": 1310720, - "max_output_tokens": 262144, - "max_tokens": 262144, + "max_output_tokens": 943717, + "max_tokens": 943717, "mode": "chat", - "source": "https://openrouter.ai/z-ai/glm-5.3", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_parallel_function_calling": true, + "supports_pdf_input": false, "supports_vision": false, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/qwen/qwen3.8-27b": { "input_cost_per_token": 2.14e-07, @@ -65270,13 +65912,16 @@ "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3.8-27b", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/qwen/qwen3.8-2.4t-a95b": { "input_cost_per_token": 2e-06, @@ -65284,16 +65929,19 @@ "cache_read_input_token_cost": 2.5e-07, "litellm_provider": "openrouter", "max_input_tokens": 1048576, - "max_output_tokens": 262144, - "max_tokens": 262144, + "max_output_tokens": 131072, + "max_tokens": 131072, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3.8-2.4t-a95b", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": false, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/nvidia/nemotron-3.5-lightning:free": { "input_cost_per_token": 0.0, @@ -65303,11 +65951,16 @@ "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", - "source": "https://openrouter.ai/nvidia/nemotron-3.5-lightning:free", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": true, "supports_reasoning": true, - "supports_vision": false + "supports_response_schema": false, + "supports_vision": false, + "supports_web_search": false }, "openrouter/qwen/qwen3.8-max": { "input_cost_per_token": 2e-06, @@ -65337,14 +65990,16 @@ "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3.8-max-0902", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, "supports_pdf_input": false, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/deepseek/deepseek-v4-flash-0731": { "input_cost_per_token": 6e-08, @@ -65355,14 +66010,17 @@ "max_output_tokens": 943718, "max_tokens": 943718, "mode": "chat", - "source": "https://openrouter.ai/deepseek/deepseek-v4-flash-0731", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_parallel_function_calling": true, + "supports_pdf_input": false, "supports_vision": false, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/qwen/qwen3.7-flash": { "input_cost_per_token": 3e-08, @@ -65378,13 +66036,16 @@ "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3.7-flash", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/poolside/laguna-s-2.1": { "input_cost_per_token": 9e-08, @@ -65395,12 +66056,16 @@ "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", - "source": "https://openrouter.ai/poolside/laguna-s-2.1", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_vision": false, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_response_schema": false, + "supports_web_search": false }, "openrouter/poolside/laguna-s-2.1:free": { "input_cost_per_token": 0.0, @@ -65410,28 +66075,36 @@ "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", - "source": "https://openrouter.ai/poolside/laguna-s-2.1:free", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": true, "supports_reasoning": true, - "supports_vision": false + "supports_response_schema": false, + "supports_vision": false, + "supports_web_search": false }, "openrouter/moonshotai/kimi-k3": { - "input_cost_per_token": 3e-06, - "output_cost_per_token": 1.5e-05, - "cache_read_input_token_cost": 3e-07, + "input_cost_per_token": 2.1e-06, + "output_cost_per_token": 1.095e-05, + "cache_read_input_token_cost": 2.3e-07, "litellm_provider": "openrouter", "max_input_tokens": 1048576, "max_output_tokens": 943718, "max_tokens": 943718, "mode": "chat", - "source": "https://openrouter.ai/moonshotai/kimi-k3", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/poolside/laguna-xs-2.1": { "input_cost_per_token": 6e-08, @@ -65442,12 +66115,16 @@ "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", - "source": "https://openrouter.ai/poolside/laguna-xs-2.1", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_vision": false, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_response_schema": false, + "supports_web_search": false }, "openrouter/poolside/laguna-xs-2.1:free": { "input_cost_per_token": 0.0, @@ -65457,11 +66134,16 @@ "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", - "source": "https://openrouter.ai/poolside/laguna-xs-2.1:free", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": true, "supports_reasoning": true, - "supports_vision": false + "supports_response_schema": false, + "supports_vision": false, + "supports_web_search": false }, "openrouter/google/gemini-3.1-flash-lite-image": { "input_cost_per_token": 2.5e-07, @@ -65472,12 +66154,16 @@ "max_output_tokens": 58982, "max_tokens": 58982, "mode": "chat", - "source": "https://openrouter.ai/google/gemini-3.1-flash-lite-image", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": false, "supports_reasoning": true, "supports_response_schema": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/google/gemini-3.1-flash-image": { "input_cost_per_token": 5e-07, @@ -65488,18 +66174,23 @@ "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", - "source": "https://openrouter.ai/google/gemini-3.1-flash-image", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": false, "supports_reasoning": true, "supports_response_schema": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/google/gemini-3-pro-image": { "input_cost_per_token": 2e-06, "output_cost_per_token": 1.2e-05, "cache_read_input_token_cost": 2e-07, "cache_creation_input_token_cost": 3.75e-07, + "cache_read_input_audio_token_cost": 2e-07, "input_cost_per_audio_token": 2e-06, "output_cost_per_image_token": 0.00012, "litellm_provider": "openrouter", @@ -65507,46 +66198,56 @@ "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", - "source": "https://openrouter.ai/google/gemini-3-pro-image", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/z-ai/glm-5.2": { - "input_cost_per_token": 1.4e-06, - "output_cost_per_token": 4.4e-06, - "cache_read_input_token_cost": 1.4e-07, + "input_cost_per_token": 4.875e-07, + "output_cost_per_token": 1.56e-06, + "cache_read_input_token_cost": 9.1e-08, "litellm_provider": "openrouter", "max_input_tokens": 1048576, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", - "source": "https://openrouter.ai/z-ai/glm-5.2", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_parallel_function_calling": true, + "supports_pdf_input": false, "supports_vision": false, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/z-ai/glm-5.2:free": { "input_cost_per_token": 0.0, "output_cost_per_token": 0.0, "litellm_provider": "openrouter", - "max_input_tokens": 256000, - "max_output_tokens": 230400, - "max_tokens": 230400, + "max_input_tokens": 32768, + "max_output_tokens": 29491, + "max_tokens": 29491, "mode": "chat", - "source": "https://openrouter.ai/z-ai/glm-5.2:free", - "supports_function_calling": true, - "supports_tool_choice": true, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_tool_choice": false, "supports_reasoning": true, - "supports_response_schema": true, - "supports_vision": false + "supports_response_schema": false, + "supports_vision": false, + "supports_web_search": false }, "openrouter/moonshotai/kimi-k2.7-code": { "input_cost_per_token": 7.062e-07, @@ -65557,14 +66258,17 @@ "max_output_tokens": 235929, "max_tokens": 235929, "mode": "chat", - "source": "https://openrouter.ai/moonshotai/kimi-k2.7-code", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_parallel_function_calling": true, + "supports_pdf_input": false, "supports_vision": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/nvidia/nemotron-3.5-content-safety": { "input_cost_per_token": 2e-07, @@ -65574,12 +66278,16 @@ "max_output_tokens": 117964, "max_tokens": 117964, "mode": "chat", - "source": "https://openrouter.ai/nvidia/nemotron-3.5-content-safety", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": false, "supports_reasoning": true, - "supports_response_schema": true, - "supports_vision": true + "supports_response_schema": false, + "supports_vision": true, + "supports_web_search": false }, "openrouter/nvidia/nemotron-3.5-content-safety:free": { "input_cost_per_token": 0.0, @@ -65589,11 +66297,16 @@ "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", - "source": "https://openrouter.ai/nvidia/nemotron-3.5-content-safety:free", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": false, "supports_reasoning": true, - "supports_vision": true + "supports_response_schema": false, + "supports_vision": true, + "supports_web_search": false }, "openrouter/nvidia/nemotron-3-ultra-550b-a55b": { "input_cost_per_token": 6.25e-07, @@ -65604,13 +66317,16 @@ "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", - "source": "https://openrouter.ai/nvidia/nemotron-3-ultra-550b-a55b", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": false, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/nvidia/nemotron-3-ultra-550b-a55b:free": { "input_cost_per_token": 0.0, @@ -65620,11 +66336,16 @@ "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", - "source": "https://openrouter.ai/nvidia/nemotron-3-ultra-550b-a55b:free", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": true, "supports_reasoning": true, - "supports_vision": false + "supports_response_schema": false, + "supports_vision": false, + "supports_web_search": false }, "openrouter/minimax/minimax-m3:free": { "input_cost_per_token": 0.0, @@ -65651,13 +66372,16 @@ "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3.7-max", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": false, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/mistralai/mistral-medium-3-5": { "input_cost_per_token": 1.5e-06, @@ -65667,13 +66391,16 @@ "max_output_tokens": 209715, "max_tokens": 209715, "mode": "chat", - "source": "https://openrouter.ai/mistralai/mistral-medium-3-5", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, - "supports_pdf_input": true + "supports_pdf_input": true, + "supports_prompt_caching": false, + "supports_web_search": false }, "openrouter/nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free": { "input_cost_per_token": 0.0, @@ -65683,12 +66410,16 @@ "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", - "source": "https://openrouter.ai/nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, "supports_vision": true, - "supports_audio_input": true + "supports_audio_input": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_response_schema": false, + "supports_web_search": false }, "openrouter/qwen/qwen3.5-plus-20260420": { "input_cost_per_token": 3e-07, @@ -65702,12 +66433,16 @@ "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3.5-plus-20260420", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/qwen/qwen3.6-flash": { "input_cost_per_token": 1.875e-07, @@ -65721,12 +66456,16 @@ "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3.6-flash", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/qwen/qwen3.6-35b-a3b": { "input_cost_per_token": 1e-07, @@ -65737,13 +66476,16 @@ "max_output_tokens": 235929, "max_tokens": 235929, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3.6-35b-a3b", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/qwen/qwen3.6-max-preview": { "input_cost_per_token": 1.027e-06, @@ -65757,12 +66499,16 @@ "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3.6-max-preview", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, - "supports_vision": false + "supports_vision": false, + "supports_web_search": false }, "openrouter/qwen/qwen3.6-27b": { "input_cost_per_token": 3e-07, @@ -65773,13 +66519,16 @@ "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3.6-27b", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/openai/gpt-5.5-pro": { "input_cost_per_token": 3e-05, @@ -65791,13 +66540,16 @@ "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "source": "https://openrouter.ai/openai/gpt-5.5-pro", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, - "supports_pdf_input": true + "supports_pdf_input": true, + "supports_prompt_caching": false, + "supports_web_search": false }, "openrouter/openai/gpt-chat-latest": { "input_cost_per_token": 5e-06, @@ -65808,31 +66560,36 @@ "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "source": "https://openrouter.ai/openai/gpt-chat-latest", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": false, "supports_response_schema": true, "supports_vision": true, "supports_pdf_input": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/deepseek/deepseek-v4-flash": { - "input_cost_per_token": 8.54e-08, - "output_cost_per_token": 1.708e-07, - "cache_read_input_token_cost": 1.708e-08, + "input_cost_per_token": 8.8606e-08, + "output_cost_per_token": 1.77212e-07, + "cache_read_input_token_cost": 1.77212e-08, "litellm_provider": "openrouter", "max_input_tokens": 1048576, "max_output_tokens": 384000, "max_tokens": 384000, "mode": "chat", - "source": "https://openrouter.ai/deepseek/deepseek-v4-flash", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": false, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/moonshotai/kimi-k2.6": { "input_cost_per_token": 9.5e-07, @@ -65843,29 +66600,37 @@ "max_output_tokens": 235929, "max_tokens": 235929, "mode": "chat", - "source": "https://openrouter.ai/moonshotai/kimi-k2.6", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_parallel_function_calling": true, + "supports_pdf_input": false, "supports_vision": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/google/gemma-4-26b-a4b-it": { + "cache_read_input_token_cost": 5e-08, "input_cost_per_token": 9e-08, "output_cost_per_token": 3e-07, "litellm_provider": "openrouter", "max_input_tokens": 262144, - "max_output_tokens": 16384, - "max_tokens": 16384, + "max_output_tokens": 235929, + "max_tokens": 235929, "mode": "chat", - "source": "https://openrouter.ai/google/gemma-4-26b-a4b-it", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/google/gemma-4-26b-a4b-it:free": { "input_cost_per_token": 0.0, @@ -65875,12 +66640,16 @@ "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", - "source": "https://openrouter.ai/google/gemma-4-26b-a4b-it:free", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/google/gemma-4-31b-it": { "input_cost_per_token": 9e-08, @@ -65891,13 +66660,16 @@ "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", - "source": "https://openrouter.ai/google/gemma-4-31b-it", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/google/gemma-4-31b-it:free": { "input_cost_per_token": 0.0, @@ -65907,29 +66679,37 @@ "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", - "source": "https://openrouter.ai/google/gemma-4-31b-it:free", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/z-ai/glm-5v-turbo": { "input_cost_per_token": 1.2e-06, "output_cost_per_token": 4e-06, "cache_read_input_token_cost": 2.4e-07, + "deprecation_date": "2098-12-31", "litellm_provider": "openrouter", "max_input_tokens": 202752, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", - "source": "https://openrouter.ai/z-ai/glm-5v-turbo", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/minimax/minimax-m2.7": { "input_cost_per_token": 3e-07, @@ -65940,13 +66720,16 @@ "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", - "source": "https://openrouter.ai/minimax/minimax-m2.7", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": false, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/minimax/minimax-m2.7:free": { "input_cost_per_token": 0.0, @@ -65972,45 +66755,56 @@ "max_output_tokens": 209715, "max_tokens": 209715, "mode": "chat", - "source": "https://openrouter.ai/mistralai/mistral-small-2603", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/z-ai/glm-5-turbo": { "input_cost_per_token": 1.2e-06, "output_cost_per_token": 4e-06, "cache_read_input_token_cost": 2.4e-07, + "deprecation_date": "2098-12-31", "litellm_provider": "openrouter", "max_input_tokens": 202752, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", - "source": "https://openrouter.ai/z-ai/glm-5-turbo", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": false, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/nvidia/nemotron-3-super-120b-a12b": { - "input_cost_per_token": 8.5e-08, - "output_cost_per_token": 4e-07, + "input_cost_per_token": 8e-08, + "output_cost_per_token": 4.5e-07, "litellm_provider": "openrouter", - "max_input_tokens": 1000000, - "max_output_tokens": 16384, - "max_tokens": 16384, + "max_input_tokens": 262144, + "max_output_tokens": 235929, + "max_tokens": 235929, "mode": "chat", - "source": "https://openrouter.ai/nvidia/nemotron-3-super-120b-a12b", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, - "supports_vision": false + "supports_vision": false, + "supports_web_search": false }, "openrouter/nvidia/nemotron-3-super-120b-a12b:free": { "input_cost_per_token": 0.0, @@ -66020,12 +66814,16 @@ "max_output_tokens": 235929, "max_tokens": 235929, "mode": "chat", - "source": "https://openrouter.ai/nvidia/nemotron-3-super-120b-a12b:free", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, - "supports_vision": false + "supports_vision": false, + "supports_web_search": false }, "openrouter/qwen/qwen3.5-9b": { "input_cost_per_token": 1e-07, @@ -66035,12 +66833,16 @@ "max_output_tokens": 235929, "max_tokens": 235929, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3.5-9b", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/openai/gpt-5.4-pro": { "input_cost_per_token": 3e-05, @@ -66052,13 +66854,16 @@ "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "source": "https://openrouter.ai/openai/gpt-5.4-pro", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, - "supports_pdf_input": true + "supports_pdf_input": true, + "supports_prompt_caching": false, + "supports_web_search": false }, "openrouter/google/gemini-3.1-flash-image-preview": { "input_cost_per_token": 5e-07, @@ -66069,18 +66874,23 @@ "max_output_tokens": 58982, "max_tokens": 58982, "mode": "chat", - "source": "https://openrouter.ai/google/gemini-3.1-flash-image-preview", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": false, "supports_reasoning": true, "supports_response_schema": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/google/gemini-3.1-pro-preview-customtools": { "input_cost_per_token": 2e-06, "output_cost_per_token": 1.2e-05, "cache_read_input_token_cost": 2e-07, "cache_creation_input_token_cost": 3.75e-07, + "cache_read_input_audio_token_cost": 2e-07, "input_cost_per_audio_token": 2e-06, "input_cost_per_token_above_200k_tokens": 4e-06, "output_cost_per_token_above_200k_tokens": 1.8e-05, @@ -66090,7 +66900,7 @@ "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", - "source": "https://openrouter.ai/google/gemini-3.1-pro-preview-customtools", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, @@ -66098,7 +66908,8 @@ "supports_vision": true, "supports_pdf_input": true, "supports_audio_input": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/qwen/qwen3-max-thinking": { "input_cost_per_token": 7.8e-07, @@ -66110,12 +66921,16 @@ "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3-max-thinking", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, - "supports_vision": false + "supports_vision": false, + "supports_web_search": false }, "openrouter/qwen/qwen3-coder-next": { "input_cost_per_token": 1.2e-07, @@ -66126,12 +66941,16 @@ "max_output_tokens": 235929, "max_tokens": 235929, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3-coder-next", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_response_schema": true, "supports_vision": false, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_web_search": false }, "openrouter/minimax/minimax-m2-her": { "input_cost_per_token": 3e-07, @@ -66142,11 +66961,16 @@ "max_output_tokens": 2048, "max_tokens": 2048, "mode": "chat", - "source": "https://openrouter.ai/minimax/minimax-m2-her", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": false, + "supports_pdf_input": false, "supports_tool_choice": false, "supports_vision": false, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": false, + "supports_web_search": false }, "openrouter/openai/gpt-audio": { "input_cost_per_token": 2.5e-06, @@ -66158,12 +66982,16 @@ "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", - "source": "https://openrouter.ai/openai/gpt-audio", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_response_schema": true, "supports_vision": false, - "supports_audio_input": true + "supports_audio_input": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_web_search": false }, "openrouter/openai/gpt-audio-mini": { "input_cost_per_token": 6e-07, @@ -66175,29 +67003,36 @@ "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", - "source": "https://openrouter.ai/openai/gpt-audio-mini", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_response_schema": true, "supports_vision": false, - "supports_audio_input": true + "supports_audio_input": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_web_search": false }, "openrouter/nvidia/nemotron-3-nano-30b-a3b": { - "input_cost_per_token": 5e-08, - "output_cost_per_token": 2e-07, + "input_cost_per_token": 6e-08, + "output_cost_per_token": 2.4e-07, "cache_read_input_token_cost": 3e-08, "litellm_provider": "openrouter", "max_input_tokens": 262144, "max_output_tokens": 235929, "max_tokens": 235929, "mode": "chat", - "source": "https://openrouter.ai/nvidia/nemotron-3-nano-30b-a3b", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": false, - "supports_prompt_caching": true + "supports_prompt_caching": false, + "supports_web_search": false }, "openrouter/z-ai/glm-4.6v": { "input_cost_per_token": 3e-07, @@ -66208,19 +67043,23 @@ "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", - "source": "https://openrouter.ai/z-ai/glm-4.6v", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/google/gemini-3-pro-image-preview": { "input_cost_per_token": 2e-06, "output_cost_per_token": 1.2e-05, "cache_read_input_token_cost": 2e-07, "cache_creation_input_token_cost": 3.75e-07, + "cache_read_input_audio_token_cost": 2e-07, "input_cost_per_audio_token": 2e-06, "output_cost_per_image_token": 0.00012, "litellm_provider": "openrouter", @@ -66228,13 +67067,16 @@ "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", - "source": "https://openrouter.ai/google/gemini-3-pro-image-preview", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": false, + "supports_pdf_input": false, "supports_tool_choice": false, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/openai/gpt-5.1-codex": { "input_cost_per_token": 1.25e-06, @@ -66245,13 +67087,16 @@ "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "source": "https://openrouter.ai/openai/gpt-5.1-codex", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/openai/gpt-5.1-codex-mini": { "input_cost_per_token": 2.5e-07, @@ -66262,13 +67107,16 @@ "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "source": "https://openrouter.ai/openai/gpt-5.1-codex-mini", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/moonshotai/kimi-k2-thinking": { "input_cost_per_token": 6e-07, @@ -66276,16 +67124,19 @@ "cache_read_input_token_cost": 1.5e-07, "litellm_provider": "openrouter", "max_input_tokens": 262144, - "max_output_tokens": 100352, - "max_tokens": 100352, + "max_output_tokens": 98304, + "max_tokens": 98304, "mode": "chat", - "source": "https://openrouter.ai/moonshotai/kimi-k2-thinking", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": false, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/mistralai/voxtral-small-24b-2507": { "input_cost_per_token": 1e-07, @@ -66297,14 +67148,16 @@ "max_output_tokens": 26214, "max_tokens": 26214, "mode": "chat", - "source": "https://openrouter.ai/mistralai/voxtral-small-24b-2507", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_response_schema": true, "supports_vision": false, "supports_pdf_input": true, "supports_audio_input": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_web_search": false }, "openrouter/openai/gpt-oss-safeguard-20b": { "input_cost_per_token": 7.5e-08, @@ -66315,13 +67168,16 @@ "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", - "source": "https://openrouter.ai/openai/gpt-oss-safeguard-20b", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": false, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/qwen/qwen3-vl-32b-instruct": { "input_cost_per_token": 1.04e-07, @@ -66331,11 +67187,16 @@ "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3-vl-32b-instruct", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, "supports_tool_choice": true, "supports_response_schema": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/qwen/qwen3-vl-8b-thinking": { "input_cost_per_token": 1.8e-07, @@ -66345,12 +67206,16 @@ "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3-vl-8b-thinking", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/qwen/qwen3-vl-8b-instruct": { "input_cost_per_token": 1.17e-07, @@ -66360,17 +67225,23 @@ "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3-vl-8b-instruct", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, "supports_tool_choice": true, "supports_response_schema": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/google/gemini-2.5-flash-image": { "input_cost_per_token": 3e-07, "output_cost_per_token": 2.5e-06, "cache_read_input_token_cost": 3e-08, "cache_creation_input_token_cost": 8.33333333333333e-08, + "cache_read_input_audio_token_cost": 1e-07, "input_cost_per_audio_token": 1e-06, "output_cost_per_image_token": 3e-05, "litellm_provider": "openrouter", @@ -66378,12 +67249,16 @@ "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", - "source": "https://openrouter.ai/google/gemini-2.5-flash-image", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": false, + "supports_pdf_input": false, "supports_tool_choice": false, "supports_response_schema": true, "supports_vision": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_web_search": false }, "openrouter/qwen/qwen3-vl-30b-a3b-thinking": { "input_cost_per_token": 2e-07, @@ -66393,26 +67268,35 @@ "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3-vl-30b-a3b-thinking", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/qwen/qwen3-vl-30b-a3b-instruct": { "input_cost_per_token": 1.3e-07, "output_cost_per_token": 5.2e-07, "litellm_provider": "openrouter", "max_input_tokens": 262144, - "max_output_tokens": 16384, - "max_tokens": 16384, + "max_output_tokens": 32768, + "max_tokens": 32768, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3-vl-30b-a3b-instruct", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, "supports_tool_choice": true, "supports_response_schema": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/openai/gpt-5-pro": { "input_cost_per_token": 1.5e-05, @@ -66422,13 +67306,16 @@ "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "source": "https://openrouter.ai/openai/gpt-5-pro", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, - "supports_pdf_input": true + "supports_pdf_input": true, + "supports_prompt_caching": false, + "supports_web_search": false }, "openrouter/qwen/qwen3-vl-235b-a22b-thinking": { "input_cost_per_token": 4e-07, @@ -66438,12 +67325,16 @@ "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3-vl-235b-a22b-thinking", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/qwen/qwen3-vl-235b-a22b-instruct": { "input_cost_per_token": 2.1e-07, @@ -66454,12 +67345,16 @@ "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3-vl-235b-a22b-instruct", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_response_schema": true, "supports_vision": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_web_search": false }, "openrouter/qwen/qwen3-max": { "input_cost_per_token": 7.8e-07, @@ -66475,12 +67370,16 @@ "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3-max", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_response_schema": true, "supports_vision": false, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_web_search": false }, "openrouter/deepseek/deepseek-v3.1-terminus": { "input_cost_per_token": 2.7e-07, @@ -66491,13 +67390,16 @@ "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", - "source": "https://openrouter.ai/deepseek/deepseek-v3.1-terminus", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": false, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/qwen/qwen3-coder-flash": { "input_cost_per_token": 1.95e-07, @@ -66513,12 +67415,16 @@ "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3-coder-flash", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_response_schema": true, "supports_vision": false, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_web_search": false }, "openrouter/qwen/qwen3-next-80b-a3b-thinking": { "input_cost_per_token": 1.5e-07, @@ -66528,12 +67434,16 @@ "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3-next-80b-a3b-thinking", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, - "supports_vision": false + "supports_vision": false, + "supports_web_search": false }, "openrouter/qwen/qwen3-next-80b-a3b-instruct": { "input_cost_per_token": 9e-08, @@ -66541,17 +67451,23 @@ "cache_read_input_token_cost": 7e-08, "litellm_provider": "openrouter", "max_input_tokens": 262144, - "max_output_tokens": 235929, - "max_tokens": 235929, + "max_output_tokens": 16384, + "max_tokens": 16384, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3-next-80b-a3b-instruct", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_response_schema": true, "supports_vision": false, - "supports_prompt_caching": true + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_web_search": false }, "openrouter/qwen/qwen-plus-2025-07-28": { + "cache_creation_input_token_cost": 3.25e-07, + "cache_read_input_token_cost": 5.2e-08, "input_cost_per_token": 2.6e-07, "output_cost_per_token": 7.8e-07, "input_cost_per_token_above_256k_tokens": 7.8e-07, @@ -66561,25 +67477,35 @@ "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen-plus-2025-07-28", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": false, "supports_tool_choice": true, "supports_response_schema": true, - "supports_vision": false + "supports_vision": false, + "supports_web_search": false }, "openrouter/moonshotai/kimi-k2-0905": { "input_cost_per_token": 6e-07, "output_cost_per_token": 2.5e-06, "litellm_provider": "openrouter", "max_input_tokens": 262144, - "max_output_tokens": 100352, - "max_tokens": 100352, + "max_output_tokens": 98304, + "max_tokens": 98304, "mode": "chat", - "source": "https://openrouter.ai/moonshotai/kimi-k2-0905", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, "supports_tool_choice": true, "supports_response_schema": true, - "supports_vision": false + "supports_vision": false, + "supports_web_search": false }, "openrouter/qwen/qwen3-30b-a3b-thinking-2507": { "input_cost_per_token": 2e-07, @@ -66589,12 +67515,16 @@ "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3-30b-a3b-thinking-2507", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, - "supports_vision": false + "supports_vision": false, + "supports_web_search": false }, "openrouter/mistralai/mistral-medium-3.1": { "input_cost_per_token": 4e-07, @@ -66605,13 +67535,16 @@ "max_output_tokens": 104857, "max_tokens": 104857, "mode": "chat", - "source": "https://openrouter.ai/mistralai/mistral-medium-3.1", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_response_schema": true, "supports_vision": true, "supports_pdf_input": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_web_search": false }, "openrouter/z-ai/glm-4.5v": { "input_cost_per_token": 6e-07, @@ -66622,13 +67555,16 @@ "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", - "source": "https://openrouter.ai/z-ai/glm-4.5v", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/mistralai/codestral-2508": { "input_cost_per_token": 3e-07, @@ -66639,13 +67575,16 @@ "max_output_tokens": 204800, "max_tokens": 204800, "mode": "chat", - "source": "https://openrouter.ai/mistralai/codestral-2508", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_response_schema": true, "supports_vision": false, "supports_pdf_input": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_web_search": false }, "openrouter/qwen/qwen3-coder-30b-a3b-instruct": { "input_cost_per_token": 7e-08, @@ -66655,11 +67594,16 @@ "max_output_tokens": 235929, "max_tokens": 235929, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3-coder-30b-a3b-instruct", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, "supports_tool_choice": true, "supports_response_schema": true, - "supports_vision": false + "supports_vision": false, + "supports_web_search": false }, "openrouter/qwen/qwen3-30b-a3b-instruct-2507": { "input_cost_per_token": 4.815e-08, @@ -66669,28 +67613,37 @@ "max_output_tokens": 32000, "max_tokens": 32000, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3-30b-a3b-instruct-2507", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, "supports_tool_choice": true, "supports_response_schema": true, - "supports_vision": false + "supports_vision": false, + "supports_web_search": false }, "openrouter/z-ai/glm-4.5": { "input_cost_per_token": 6e-07, "output_cost_per_token": 2.2e-06, "cache_read_input_token_cost": 1.1e-07, + "deprecation_date": "2026-12-31", "litellm_provider": "openrouter", "max_input_tokens": 131072, "max_output_tokens": 98304, "max_tokens": 98304, "mode": "chat", - "source": "https://openrouter.ai/z-ai/glm-4.5", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": false, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/z-ai/glm-4.5-air": { "input_cost_per_token": 1.3e-07, @@ -66701,25 +67654,35 @@ "max_output_tokens": 98304, "max_tokens": 98304, "mode": "chat", - "source": "https://openrouter.ai/z-ai/glm-4.5-air", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_vision": false, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_response_schema": false, + "supports_web_search": false }, "openrouter/moonshotai/kimi-k2": { "input_cost_per_token": 5.7e-07, "output_cost_per_token": 2.3e-06, "litellm_provider": "openrouter", "max_input_tokens": 131072, - "max_output_tokens": 100352, - "max_tokens": 100352, + "max_output_tokens": 98304, + "max_tokens": 98304, "mode": "chat", - "source": "https://openrouter.ai/moonshotai/kimi-k2", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": false, "supports_tool_choice": true, - "supports_vision": false + "supports_vision": false, + "supports_web_search": false }, "openrouter/minimax/minimax-m1": { "input_cost_per_token": 4e-07, @@ -66729,11 +67692,16 @@ "max_output_tokens": 40000, "max_tokens": 40000, "mode": "chat", - "source": "https://openrouter.ai/minimax/minimax-m1", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": true, "supports_reasoning": true, - "supports_vision": false + "supports_response_schema": false, + "supports_vision": false, + "supports_web_search": false }, "openrouter/openai/o3-pro": { "input_cost_per_token": 2e-05, @@ -66743,19 +67711,23 @@ "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", - "source": "https://openrouter.ai/openai/o3-pro", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, - "supports_pdf_input": true + "supports_pdf_input": true, + "supports_prompt_caching": false, + "supports_web_search": false }, "openrouter/google/gemini-2.5-pro-preview": { "input_cost_per_token": 1.25e-06, "output_cost_per_token": 1e-05, "cache_read_input_token_cost": 1.25e-07, "cache_creation_input_token_cost": 3.75e-07, + "cache_read_input_audio_token_cost": 1.25e-07, "input_cost_per_audio_token": 1.25e-06, "input_cost_per_token_above_200k_tokens": 2.5e-06, "output_cost_per_token_above_200k_tokens": 1.5e-05, @@ -66765,7 +67737,7 @@ "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", - "source": "https://openrouter.ai/google/gemini-2.5-pro-preview", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, @@ -66773,7 +67745,8 @@ "supports_vision": true, "supports_pdf_input": true, "supports_audio_input": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/mistralai/mistral-medium-3": { "input_cost_per_token": 4e-07, @@ -66784,13 +67757,16 @@ "max_output_tokens": 104857, "max_tokens": 104857, "mode": "chat", - "source": "https://openrouter.ai/mistralai/mistral-medium-3", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_response_schema": true, "supports_vision": true, "supports_pdf_input": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_web_search": false }, "openrouter/google/gemini-2.5-pro-preview-05-06": { "input_cost_per_token": 1.25e-06, @@ -66824,11 +67800,16 @@ "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", - "source": "https://openrouter.ai/meta-llama/llama-guard-4-12b", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, "supports_tool_choice": false, - "supports_response_schema": true, - "supports_vision": true + "supports_response_schema": false, + "supports_vision": true, + "supports_web_search": false }, "openrouter/qwen/qwen3-30b-a3b": { "input_cost_per_token": 1.2e-07, @@ -66838,12 +67819,16 @@ "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3-30b-a3b", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, - "supports_vision": false + "supports_vision": false, + "supports_web_search": false }, "openrouter/qwen/qwen3-8b": { "input_cost_per_token": 1.17e-07, @@ -66853,12 +67838,16 @@ "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3-8b", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, - "supports_vision": false + "supports_vision": false, + "supports_web_search": false }, "openrouter/qwen/qwen3-14b": { "input_cost_per_token": 1.2e-07, @@ -66868,12 +67857,16 @@ "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3-14b", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, - "supports_vision": false + "supports_vision": false, + "supports_web_search": false }, "openrouter/qwen/qwen3-32b": { "input_cost_per_token": 8e-08, @@ -66883,12 +67876,16 @@ "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3-32b", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, - "supports_vision": false + "supports_vision": false, + "supports_web_search": false }, "openrouter/qwen/qwen3-235b-a22b": { "input_cost_per_token": 4.55e-07, @@ -66898,12 +67895,16 @@ "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3-235b-a22b", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, - "supports_vision": false + "supports_vision": false, + "supports_web_search": false }, "openrouter/openai/o4-mini-high": { "input_cost_per_token": 1.1e-06, @@ -66914,28 +67915,35 @@ "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", - "source": "https://openrouter.ai/openai/o4-mini-high", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, "supports_pdf_input": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/meta-llama/llama-4-maverick": { "input_cost_per_token": 1.875e-07, "output_cost_per_token": 6.525e-07, "litellm_provider": "openrouter", "max_input_tokens": 1048576, - "max_output_tokens": 115200, - "max_tokens": 115200, + "max_output_tokens": 16384, + "max_tokens": 16384, "mode": "chat", - "source": "https://openrouter.ai/meta-llama/llama-4-maverick", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, "supports_tool_choice": true, "supports_response_schema": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/meta-llama/llama-4-scout": { "input_cost_per_token": 1e-07, @@ -66945,11 +67953,16 @@ "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", - "source": "https://openrouter.ai/meta-llama/llama-4-scout", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, "supports_tool_choice": true, "supports_response_schema": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/openai/o1-pro": { "input_cost_per_token": 0.00015, @@ -66959,13 +67972,16 @@ "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", - "source": "https://openrouter.ai/openai/o1-pro", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": false, "supports_tool_choice": false, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, - "supports_pdf_input": true + "supports_pdf_input": true, + "supports_prompt_caching": false, + "supports_web_search": false }, "openrouter/google/gemma-3-4b-it": { "input_cost_per_token": 5e-08, @@ -66975,11 +67991,16 @@ "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", - "source": "https://openrouter.ai/google/gemma-3-4b-it", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, "supports_tool_choice": false, "supports_response_schema": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/google/gemma-3-12b-it": { "input_cost_per_token": 5e-08, @@ -66989,11 +68010,16 @@ "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", - "source": "https://openrouter.ai/google/gemma-3-12b-it", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, "supports_tool_choice": true, "supports_response_schema": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/google/gemma-3-27b-it": { "input_cost_per_token": 8e-08, @@ -67004,12 +68030,16 @@ "max_output_tokens": 117964, "max_tokens": 117964, "mode": "chat", - "source": "https://openrouter.ai/google/gemma-3-27b-it", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_response_schema": true, "supports_vision": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_web_search": false }, "openrouter/mistralai/mistral-saba": { "input_cost_per_token": 2e-07, @@ -67020,13 +68050,16 @@ "max_output_tokens": 26214, "max_tokens": 26214, "mode": "chat", - "source": "https://openrouter.ai/mistralai/mistral-saba", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_response_schema": true, "supports_vision": false, "supports_pdf_input": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_web_search": false }, "openrouter/qwen/qwen2.5-vl-72b-instruct": { "input_cost_per_token": 8e-07, @@ -67037,12 +68070,16 @@ "max_output_tokens": 115200, "max_tokens": 115200, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen2.5-vl-72b-instruct", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": false, + "supports_pdf_input": false, "supports_tool_choice": false, "supports_response_schema": true, "supports_vision": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_web_search": false }, "openrouter/qwen/qwen-plus": { "input_cost_per_token": 2.6e-07, @@ -67058,12 +68095,16 @@ "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen-plus", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_response_schema": true, "supports_vision": false, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_web_search": false }, "openrouter/mistralai/mistral-small-24b-instruct-2501": { "input_cost_per_token": 5e-08, @@ -67073,11 +68114,16 @@ "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", - "source": "https://openrouter.ai/mistralai/mistral-small-24b-instruct-2501", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, "supports_tool_choice": false, "supports_response_schema": true, - "supports_vision": false + "supports_vision": false, + "supports_web_search": false }, "openrouter/deepseek/deepseek-r1-distill-llama-70b": { "input_cost_per_token": 8e-07, @@ -67087,11 +68133,16 @@ "max_output_tokens": 7372, "max_tokens": 7372, "mode": "chat", - "source": "https://openrouter.ai/deepseek/deepseek-r1-distill-llama-70b", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": false, "supports_reasoning": true, - "supports_vision": false + "supports_response_schema": false, + "supports_vision": false, + "supports_web_search": false }, "openrouter/minimax/minimax-01": { "input_cost_per_token": 2e-07, @@ -67101,10 +68152,16 @@ "max_output_tokens": 900172, "max_tokens": 900172, "mode": "chat", - "source": "https://openrouter.ai/minimax/minimax-01", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": false, "supports_tool_choice": false, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/meta-llama/llama-3.3-70b-instruct": { "input_cost_per_token": 1e-07, @@ -67114,11 +68171,16 @@ "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", - "source": "https://openrouter.ai/meta-llama/llama-3.3-70b-instruct", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, "supports_tool_choice": true, "supports_response_schema": true, - "supports_vision": false + "supports_vision": false, + "supports_web_search": false }, "openrouter/openai/gpt-4o-2024-11-20": { "input_cost_per_token": 2.5e-06, @@ -67129,14 +68191,16 @@ "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", - "source": "https://openrouter.ai/openai/gpt-4o-2024-11-20", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_response_schema": true, "supports_web_search": true, "supports_vision": true, "supports_pdf_input": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_reasoning": false }, "openrouter/mistralai/mistral-large-2407": { "input_cost_per_token": 2e-06, @@ -67147,13 +68211,16 @@ "max_output_tokens": 104857, "max_tokens": 104857, "mode": "chat", - "source": "https://openrouter.ai/mistralai/mistral-large-2407", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_response_schema": true, "supports_vision": false, "supports_pdf_input": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_web_search": false }, "openrouter/qwen/qwen-2.5-7b-instruct": { "input_cost_per_token": 1e-07, @@ -67163,11 +68230,16 @@ "max_output_tokens": 29491, "max_tokens": 29491, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen-2.5-7b-instruct", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, "supports_tool_choice": true, "supports_response_schema": true, - "supports_vision": false + "supports_vision": false, + "supports_web_search": false }, "openrouter/meta-llama/llama-3.2-1b-instruct": { "input_cost_per_token": 2.7e-08, @@ -67177,10 +68249,16 @@ "max_output_tokens": 54000, "max_tokens": 54000, "mode": "chat", - "source": "https://openrouter.ai/meta-llama/llama-3.2-1b-instruct", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": false, "supports_tool_choice": false, - "supports_vision": false + "supports_vision": false, + "supports_web_search": false }, "openrouter/meta-llama/llama-3.2-3b-instruct": { "input_cost_per_token": 5e-08, @@ -67190,11 +68268,16 @@ "max_output_tokens": 117964, "max_tokens": 117964, "mode": "chat", - "source": "https://openrouter.ai/meta-llama/llama-3.2-3b-instruct", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, "supports_tool_choice": false, "supports_response_schema": true, - "supports_vision": false + "supports_vision": false, + "supports_web_search": false }, "openrouter/qwen/qwen-2.5-72b-instruct": { "input_cost_per_token": 3.6e-07, @@ -67204,11 +68287,16 @@ "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen-2.5-72b-instruct", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, "supports_tool_choice": true, "supports_response_schema": true, - "supports_vision": false + "supports_vision": false, + "supports_web_search": false }, "openrouter/openai/gpt-4o-2024-08-06": { "input_cost_per_token": 2.5e-06, @@ -67219,14 +68307,16 @@ "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", - "source": "https://openrouter.ai/openai/gpt-4o-2024-08-06", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_response_schema": true, "supports_web_search": true, "supports_vision": true, "supports_pdf_input": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_reasoning": false }, "openrouter/meta-llama/llama-3.1-70b-instruct": { "input_cost_per_token": 4e-07, @@ -67236,11 +68326,16 @@ "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", - "source": "https://openrouter.ai/meta-llama/llama-3.1-70b-instruct", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, "supports_tool_choice": true, "supports_response_schema": true, - "supports_vision": false + "supports_vision": false, + "supports_web_search": false }, "openrouter/meta-llama/llama-3.1-8b-instruct": { "input_cost_per_token": 5e-08, @@ -67251,12 +68346,16 @@ "max_output_tokens": 117964, "max_tokens": 117964, "mode": "chat", - "source": "https://openrouter.ai/meta-llama/llama-3.1-8b-instruct", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_response_schema": true, "supports_vision": false, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_web_search": false }, "openrouter/mistralai/mistral-nemo": { "input_cost_per_token": 1.9e-08, @@ -67266,11 +68365,16 @@ "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", - "source": "https://openrouter.ai/mistralai/mistral-nemo", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, "supports_tool_choice": true, "supports_response_schema": true, - "supports_vision": false + "supports_vision": false, + "supports_web_search": false }, "openrouter/openai/gpt-4o-mini-2024-07-18": { "input_cost_per_token": 1.5e-07, @@ -67281,14 +68385,16 @@ "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", - "source": "https://openrouter.ai/openai/gpt-4o-mini-2024-07-18", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_response_schema": true, "supports_web_search": true, "supports_vision": true, "supports_pdf_input": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_reasoning": false }, "openrouter/google/gemma-2-27b-it": { "input_cost_per_token": 6.5e-07, @@ -67298,11 +68404,16 @@ "max_output_tokens": 2048, "max_tokens": 2048, "mode": "chat", - "source": "https://openrouter.ai/google/gemma-2-27b-it", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, "supports_tool_choice": false, "supports_response_schema": true, - "supports_vision": false + "supports_vision": false, + "supports_web_search": false }, "openrouter/openai/gpt-4-turbo": { "input_cost_per_token": 1e-05, @@ -67312,11 +68423,16 @@ "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", - "source": "https://openrouter.ai/openai/gpt-4-turbo", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, "supports_tool_choice": true, "supports_response_schema": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/openai/gpt-4-turbo-preview": { "input_cost_per_token": 1e-05, @@ -67340,11 +68456,16 @@ "max_output_tokens": 3685, "max_tokens": 3685, "mode": "chat", - "source": "https://openrouter.ai/openai/gpt-3.5-turbo-instruct", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, "supports_tool_choice": false, "supports_response_schema": true, - "supports_vision": false + "supports_vision": false, + "supports_web_search": false }, "together_ai/arcee-ai/trinity-mini": { "input_cost_per_token": 4.5e-08, @@ -69394,5 +70515,3913 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true + }, + "openrouter/~anthropic/claude-fable-latest": { + "cache_creation_input_token_cost": 1.25e-05, + "cache_creation_input_token_cost_above_1hr": 2e-05, + "cache_read_input_token_cost": 2.5e-07, + "input_cost_per_token": 1e-05, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/~anthropic/claude-haiku-latest": { + "cache_creation_input_token_cost": 1.25e-06, + "cache_creation_input_token_cost_above_1hr": 2e-06, + "cache_read_input_token_cost": 1e-07, + "input_cost_per_token": 1e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 5e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/~anthropic/claude-opus-latest": { + "cache_creation_input_token_cost": 6.25e-06, + "cache_creation_input_token_cost_above_1hr": 1e-05, + "cache_read_input_token_cost": 5e-07, + "input_cost_per_token": 5e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2.5e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/~anthropic/claude-sonnet-latest": { + "cache_creation_input_token_cost": 2.5e-06, + "cache_creation_input_token_cost_above_1hr": 4e-06, + "cache_read_input_token_cost": 2e-07, + "input_cost_per_token": 2e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/~deepseek/deepseek-flash-latest": { + "cache_read_input_token_cost": 1.5e-08, + "input_cost_per_token": 1.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 393216, + "max_tokens": 393216, + "mode": "chat", + "output_cost_per_token": 6e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/~deepseek/deepseek-pro-latest": { + "cache_read_input_token_cost": 2.2e-08, + "input_cost_per_token": 6.6e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 393216, + "max_tokens": 393216, + "mode": "chat", + "output_cost_per_token": 1.98e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/~deepseek/deepseek-v4-flash-latest": { + "cache_read_input_token_cost": 8.8e-09, + "input_cost_per_token": 5.58e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 1310720, + "max_output_tokens": 393216, + "max_tokens": 393216, + "mode": "chat", + "output_cost_per_token": 1.767e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/~google/gemini-flash-latest": { + "cache_creation_input_token_cost": 4.16666666666667e-08, + "cache_read_input_audio_token_cost": 7.5e-08, + "cache_read_input_token_cost": 7.5e-08, + "input_cost_per_audio_token": 7.5e-07, + "input_cost_per_token": 7.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 3.75e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/~google/gemini-pro-latest": { + "cache_creation_input_token_cost": 3.75e-07, + "cache_read_input_audio_token_cost": 2e-07, + "cache_read_input_token_cost": 2e-07, + "cache_read_input_token_cost_above_200k_tokens": 4e-07, + "input_cost_per_audio_token": 2e-06, + "input_cost_per_token": 2e-06, + "input_cost_per_token_above_200k_tokens": 4e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 1.2e-05, + "output_cost_per_token_above_200k_tokens": 1.8e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/~moonshotai/kimi-latest": { + "cache_read_input_token_cost": 2.3e-07, + "input_cost_per_token": 2.1e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 943718, + "max_tokens": 943718, + "mode": "chat", + "output_cost_per_token": 1.095e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/~openai/gpt-astra-latest": { + "cache_creation_input_token_cost": 1.25e-05, + "cache_creation_input_token_cost_above_272k_tokens": 2.5e-05, + "cache_read_input_token_cost": 1e-06, + "cache_read_input_token_cost_above_272k_tokens": 2e-06, + "input_cost_per_token": 1e-05, + "input_cost_per_token_above_272k_tokens": 2e-05, + "litellm_provider": "openrouter", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5e-05, + "output_cost_per_token_above_272k_tokens": 7.5e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/~openai/gpt-luna-latest": { + "cache_creation_input_token_cost": 2.5e-07, + "cache_creation_input_token_cost_above_272k_tokens": 5e-07, + "cache_read_input_token_cost": 2e-08, + "cache_read_input_token_cost_above_272k_tokens": 4e-08, + "input_cost_per_token": 2e-07, + "input_cost_per_token_above_272k_tokens": 4e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.2e-06, + "output_cost_per_token_above_272k_tokens": 1.8e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/~openai/gpt-mini-latest": { + "cache_read_input_token_cost": 7.5e-08, + "input_cost_per_token": 7.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 400000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 4.5e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/~openai/gpt-sol-latest": { + "cache_creation_input_token_cost": 2.5e-06, + "cache_creation_input_token_cost_above_272k_tokens": 5e-06, + "cache_read_input_token_cost": 2e-07, + "cache_read_input_token_cost_above_272k_tokens": 4e-07, + "input_cost_per_token": 2e-06, + "input_cost_per_token_above_272k_tokens": 4e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1e-05, + "output_cost_per_token_above_272k_tokens": 1.5e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/~openai/gpt-terra-latest": { + "cache_creation_input_token_cost": 2.5e-06, + "cache_creation_input_token_cost_above_272k_tokens": 5e-06, + "cache_read_input_token_cost": 2e-07, + "cache_read_input_token_cost_above_272k_tokens": 4e-07, + "input_cost_per_token": 2e-06, + "input_cost_per_token_above_272k_tokens": 4e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.2e-05, + "output_cost_per_token_above_272k_tokens": 1.8e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/~x-ai/grok-latest": { + "cache_read_input_token_cost": 5e-07, + "cache_read_input_token_cost_above_200k_tokens": 1e-06, + "input_cost_per_token": 2e-06, + "input_cost_per_token_above_200k_tokens": 4e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 500000, + "max_output_tokens": 450000, + "max_tokens": 450000, + "mode": "chat", + "output_cost_per_token": 6e-06, + "output_cost_per_token_above_200k_tokens": 1.2e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/~z-ai/glm-flash-latest": { + "cache_read_input_token_cost": 1.5e-08, + "input_cost_per_token": 7.5e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 1310720, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 2.5e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/~z-ai/glm-latest": { + "cache_read_input_token_cost": 1.755e-07, + "input_cost_per_token": 8.775e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1310720, + "max_output_tokens": 235929, + "max_tokens": 235929, + "mode": "chat", + "output_cost_per_token": 2.97e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/aion-labs/aion-2.0": { + "cache_read_input_token_cost": 2e-07, + "input_cost_per_token": 8e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 1.6e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/aion-labs/aion-3.0": { + "cache_read_input_token_cost": 7.5e-07, + "input_cost_per_token": 3e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 6e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/aion-labs/aion-3.0-mini": { + "cache_read_input_token_cost": 1.8e-07, + "input_cost_per_token": 7e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 1.4e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/aion-labs/aion-rp-llama-3.1-8b": { + "input_cost_per_token": 8e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 32768, + "max_output_tokens": 29491, + "max_tokens": 29491, + "mode": "chat", + "output_cost_per_token": 1.6e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": false, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/amazon/nova-2-lite-v1": { + "input_cost_per_token": 3e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 65535, + "max_tokens": 65535, + "mode": "chat", + "output_cost_per_token": 2.5e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/amazon/nova-lite-v1": { + "input_cost_per_token": 6e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 300000, + "max_output_tokens": 5120, + "max_tokens": 5120, + "mode": "chat", + "output_cost_per_token": 2.4e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": false, + "supports_tool_choice": false, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/amazon/nova-micro-v1": { + "input_cost_per_token": 3.5e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 128000, + "max_output_tokens": 5120, + "max_tokens": 5120, + "mode": "chat", + "output_cost_per_token": 1.4e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": false, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/amazon/nova-premier-v1": { + "cache_read_input_token_cost": 6.25e-07, + "input_cost_per_token": 2.5e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 1.25e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": false, + "supports_tool_choice": false, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/amazon/nova-pro-v1": { + "input_cost_per_token": 8e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 300000, + "max_output_tokens": 5120, + "max_tokens": 5120, + "mode": "chat", + "output_cost_per_token": 3.2e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": false, + "supports_tool_choice": false, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/anthracite-org/magnum-v4-72b": { + "input_cost_per_token": 2.5e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 32768, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 5e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/anthropic/claude-fable-5:batch": { + "cache_creation_input_token_cost": 6.25e-06, + "cache_creation_input_token_cost_above_1hr": 1e-05, + "cache_read_input_token_cost": 5e-07, + "input_cost_per_token": 5e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2.5e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/anthropic/claude-fable-5.1:batch": { + "cache_creation_input_token_cost": 6.25e-06, + "cache_creation_input_token_cost_above_1hr": 1e-05, + "cache_read_input_token_cost": 1.25e-07, + "input_cost_per_token": 5e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2.5e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/anthropic/claude-haiku-4.5:batch": { + "cache_creation_input_token_cost": 6.25e-07, + "cache_creation_input_token_cost_above_1hr": 1e-06, + "cache_read_input_token_cost": 5e-08, + "input_cost_per_token": 5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 2.5e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/anthropic/claude-opus-4.1:batch": { + "cache_creation_input_token_cost": 9.375e-06, + "cache_creation_input_token_cost_above_1hr": 1.5e-05, + "cache_read_input_token_cost": 7.5e-07, + "input_cost_per_token": 7.5e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 3.75e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/anthropic/claude-opus-4.5:batch": { + "cache_creation_input_token_cost": 3.125e-06, + "cache_creation_input_token_cost_above_1hr": 5e-06, + "cache_read_input_token_cost": 2.5e-07, + "input_cost_per_token": 2.5e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 1.25e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/anthropic/claude-opus-4.6:batch": { + "cache_creation_input_token_cost": 3.125e-06, + "cache_creation_input_token_cost_above_1hr": 5e-06, + "cache_read_input_token_cost": 2.5e-07, + "input_cost_per_token": 2.5e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.25e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/anthropic/claude-opus-4.7:batch": { + "cache_creation_input_token_cost": 3.125e-06, + "cache_creation_input_token_cost_above_1hr": 5e-06, + "cache_read_input_token_cost": 2.5e-07, + "input_cost_per_token": 2.5e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.25e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/anthropic/claude-opus-4.8:batch": { + "cache_creation_input_token_cost": 3.125e-06, + "cache_creation_input_token_cost_above_1hr": 5e-06, + "cache_read_input_token_cost": 2.5e-07, + "input_cost_per_token": 2.5e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.25e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/anthropic/claude-opus-5:batch": { + "cache_creation_input_token_cost": 3.125e-06, + "cache_creation_input_token_cost_above_1hr": 5e-06, + "cache_read_input_token_cost": 2.5e-07, + "input_cost_per_token": 2.5e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.25e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/anthropic/claude-sonnet-4.5:batch": { + "cache_creation_input_token_cost": 1.875e-06, + "cache_creation_input_token_cost_above_1hr": 3e-06, + "cache_creation_input_token_cost_above_200k_tokens": 3.75e-06, + "cache_read_input_token_cost": 1.5e-07, + "cache_read_input_token_cost_above_200k_tokens": 3e-07, + "input_cost_per_token": 1.5e-06, + "input_cost_per_token_above_200k_tokens": 3e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 7.5e-06, + "output_cost_per_token_above_200k_tokens": 1.125e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/anthropic/claude-sonnet-4.6:batch": { + "cache_creation_input_token_cost": 1.875e-06, + "cache_creation_input_token_cost_above_1hr": 3e-06, + "cache_read_input_token_cost": 1.5e-07, + "input_cost_per_token": 1.5e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 7.5e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/anthropic/claude-sonnet-5:batch": { + "cache_creation_input_token_cost": 1.25e-06, + "cache_creation_input_token_cost_above_1hr": 2e-06, + "cache_read_input_token_cost": 1e-07, + "input_cost_per_token": 1e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/arcee-ai/trinity-large-thinking": { + "cache_read_input_token_cost": 6e-08, + "input_cost_per_token": 2.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 80000, + "max_tokens": 80000, + "mode": "chat", + "output_cost_per_token": 8e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/baidu/ernie-4.5-vl-424b-a47b": { + "input_cost_per_token": 4.2e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 123000, + "max_output_tokens": 16000, + "max_tokens": 16000, + "mode": "chat", + "output_cost_per_token": 1.25e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_tool_choice": false, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/bytedance-seed/seed-1.6": { + "input_cost_per_token": 2.5e-07, + "input_cost_per_token_above_128k_tokens": 5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 2e-06, + "output_cost_per_token_above_128k_tokens": 4e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/bytedance-seed/seed-1.6-flash": { + "input_cost_per_token": 7.5e-08, + "input_cost_per_token_above_128k_tokens": 1e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 3e-07, + "output_cost_per_token_above_128k_tokens": 8e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/bytedance-seed/seed-2-1-turbo": { + "input_cost_per_token": 5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 235929, + "max_tokens": 235929, + "mode": "chat", + "output_cost_per_token": 2.5e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/bytedance-seed/seed-2.0-code": { + "input_cost_per_token": 5e-07, + "input_cost_per_token_above_128k_tokens": 1e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 3e-06, + "output_cost_per_token_above_128k_tokens": 6e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/bytedance-seed/seed-2.0-lite": { + "input_cost_per_token": 2.5e-07, + "input_cost_per_token_above_128k_tokens": 5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 2e-06, + "output_cost_per_token_above_128k_tokens": 4e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/bytedance-seed/seed-2.0-mini": { + "input_cost_per_token": 1e-07, + "input_cost_per_token_above_128k_tokens": 2e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 4e-07, + "output_cost_per_token_above_128k_tokens": 8e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/cognitivecomputations/dolphin-mistral-24b-venice-edition": { + "input_cost_per_token": 2e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 128000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 9e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/cohere/command-a": { + "input_cost_per_token": 2.5e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 256000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/cohere/command-r-08-2024": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 128000, + "max_output_tokens": 4000, + "max_tokens": 4000, + "mode": "chat", + "output_cost_per_token": 6e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/cohere/command-r-plus-08-2024": { + "input_cost_per_token": 2.5e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 128000, + "max_output_tokens": 4000, + "max_tokens": 4000, + "mode": "chat", + "output_cost_per_token": 1e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/cohere/command-r7b-12-2024": { + "input_cost_per_token": 3.75e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 128000, + "max_output_tokens": 4000, + "max_tokens": 4000, + "mode": "chat", + "output_cost_per_token": 1.5e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/cohere/north-mini-code:free": { + "input_cost_per_token": 0.0, + "litellm_provider": "openrouter", + "max_input_tokens": 256000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.0, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/deepseek/deepseek-v4-flash-0731:batch": { + "cache_read_input_token_cost": 3.5e-09, + "input_cost_per_token": 1.1e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 943718, + "max_tokens": 943718, + "mode": "chat", + "output_cost_per_token": 3.3e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/deepseek/deepseek-v4-flash-0731:free": { + "input_cost_per_token": 0.0, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 393216, + "max_tokens": 393216, + "mode": "chat", + "output_cost_per_token": 0.0, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/deepseek/deepseek-v4-flash-vision-exp:batch": { + "cache_read_input_token_cost": 3.5e-09, + "input_cost_per_token": 1.1e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 943718, + "max_tokens": 943718, + "mode": "chat", + "output_cost_per_token": 3.3e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/deepseek/deepseek-v4-pro-0813:batch": { + "cache_read_input_token_cost": 2.2e-08, + "input_cost_per_token": 6.6e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 943718, + "max_tokens": 943718, + "mode": "chat", + "output_cost_per_token": 1.98e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/dots-studio/dots-3-note-preview:free": { + "deprecation_date": "2026-09-30", + "input_cost_per_token": 0.0, + "litellm_provider": "openrouter", + "max_input_tokens": 512000, + "max_output_tokens": 460800, + "max_tokens": 460800, + "mode": "chat", + "output_cost_per_token": 0.0, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/google/gemini-2.5-flash-lite:batch": { + "cache_read_input_audio_token_cost": 3e-08, + "cache_read_input_token_cost": 1e-08, + "input_cost_per_audio_token": 1.5e-07, + "input_cost_per_token": 5e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_tokens": 65535, + "mode": "chat", + "output_cost_per_token": 2e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/google/gemini-2.5-flash:batch": { + "cache_read_input_audio_token_cost": 1e-07, + "cache_read_input_token_cost": 3e-08, + "input_cost_per_audio_token": 5e-07, + "input_cost_per_token": 1.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_tokens": 65535, + "mode": "chat", + "output_cost_per_token": 1.25e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/google/gemini-2.5-pro:batch": { + "cache_read_input_audio_token_cost": 1.25e-07, + "cache_read_input_token_cost": 1.25e-07, + "cache_read_input_token_cost_above_200k_tokens": 2.5e-07, + "input_cost_per_audio_token": 6.25e-07, + "input_cost_per_token": 6.25e-07, + "input_cost_per_token_above_200k_tokens": 1.25e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 5e-06, + "output_cost_per_token_above_200k_tokens": 7.5e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/google/gemini-3-flash-preview:batch": { + "input_cost_per_audio_token": 5e-07, + "input_cost_per_token": 2.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 1.5e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/google/gemini-3.1-flash-lite:batch": { + "cache_read_input_audio_token_cost": 2.5e-08, + "cache_read_input_token_cost": 1.25e-08, + "input_cost_per_audio_token": 2.5e-07, + "input_cost_per_token": 1.25e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 7.5e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/google/gemini-3.1-pro-preview:batch": { + "input_cost_per_audio_token": 1e-06, + "input_cost_per_token": 1e-06, + "input_cost_per_token_above_200k_tokens": 2e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 6e-06, + "output_cost_per_token_above_200k_tokens": 9e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/google/gemini-3.5-flash-lite:batch": { + "cache_read_input_audio_token_cost": 1.5e-08, + "cache_read_input_token_cost": 1.5e-08, + "input_cost_per_audio_token": 1.5e-07, + "input_cost_per_token": 1.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 1.25e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/google/gemini-3.5-flash:batch": { + "cache_read_input_audio_token_cost": 1.5e-07, + "cache_read_input_token_cost": 7.5e-08, + "input_cost_per_audio_token": 1.5e-06, + "input_cost_per_token": 7.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 4.5e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/google/gemini-3.6-flash:batch": { + "cache_creation_input_token_cost": 4.16666666666667e-08, + "cache_read_input_audio_token_cost": 3.75e-08, + "cache_read_input_token_cost": 3.75e-08, + "input_cost_per_audio_token": 3.75e-07, + "input_cost_per_token": 3.75e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 1.875e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/google/gemini-3.7-flash:batch": { + "cache_creation_input_token_cost": 4.16666666666667e-08, + "cache_read_input_audio_token_cost": 3.75e-08, + "cache_read_input_token_cost": 3.75e-08, + "input_cost_per_audio_token": 3.75e-07, + "input_cost_per_token": 3.75e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 1.875e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/google/gemini-3.8-flash:batch": { + "cache_creation_input_token_cost": 4.16666666666667e-08, + "cache_read_input_audio_token_cost": 3.75e-08, + "cache_read_input_token_cost": 3.75e-08, + "input_cost_per_audio_token": 3.75e-07, + "input_cost_per_token": 3.75e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 1.875e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/ibm-granite/granite-4.0-h-micro": { + "input_cost_per_token": 1.7e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 131000, + "max_output_tokens": 117900, + "max_tokens": 117900, + "mode": "chat", + "output_cost_per_token": 1.12e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/ibm-granite/granite-4.2-8b": { + "cache_read_input_token_cost": 1.5e-08, + "input_cost_per_token": 6e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 117964, + "max_tokens": 117964, + "mode": "chat", + "output_cost_per_token": 2.5e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/inception/mercury-2": { + "cache_read_input_token_cost": 2.5e-08, + "input_cost_per_token": 2.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 128000, + "max_output_tokens": 50000, + "max_tokens": 50000, + "mode": "chat", + "output_cost_per_token": 7.5e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/inception/mercury-2.5": { + "cache_read_input_token_cost": 4e-09, + "input_cost_per_token": 4e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 260000, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 1.5e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/inclusionai/ling-3.0-flash": { + "cache_read_input_token_cost": 4.2e-09, + "input_cost_per_token": 2.1e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 6.3e-08, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/inclusionai/ling-3.0-flash-fin": { + "cache_read_input_token_cost": 1.2e-08, + "input_cost_per_token": 6e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 235929, + "max_tokens": 235929, + "mode": "chat", + "output_cost_per_token": 1.8e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/inclusionai/ling-3.0-flash-fin:free": { + "input_cost_per_token": 0.0, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 0.0, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/inclusionai/ling-3.0-flash-sante:free": { + "input_cost_per_token": 0.0, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 0.0, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/inclusionai/ling-3.0-flash-vl": { + "cache_read_input_token_cost": 1.2e-08, + "input_cost_per_token": 6e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 1.8e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/inclusionai/ling-3.0-flash-vl:free": { + "input_cost_per_token": 0.0, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 0.0, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/inference-net/schematron-v2-small": { + "cache_read_input_token_cost": 5e-08, + "input_cost_per_token": 5e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 2.3e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/inference-net/schematron-v2-turbo": { + "cache_read_input_token_cost": 3e-08, + "input_cost_per_token": 3e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 128000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.5e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/kwaipilot/kat-coder-pro-v2": { + "cache_read_input_token_cost": 6e-08, + "input_cost_per_token": 3e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 144000, + "max_tokens": 144000, + "mode": "chat", + "output_cost_per_token": 1.2e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/kwaipilot/kat-coder-pro-v2.5": { + "cache_read_input_token_cost": 1.5e-07, + "input_cost_per_token": 7.4e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 235929, + "max_tokens": 235929, + "mode": "chat", + "output_cost_per_token": 2.96e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/liquid/lfm-2.5-2.6b:free": { + "input_cost_per_token": 0.0, + "litellm_provider": "openrouter", + "max_input_tokens": 65536, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.0, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/meituan/longcat-2.0": { + "cache_read_input_token_cost": 6e-09, + "input_cost_per_token": 3e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1048756, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 1.2e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/meta/muse-glimmer-30b": { + "cache_read_input_token_cost": 4e-08, + "input_cost_per_token": 3e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 117964, + "max_tokens": 117964, + "mode": "chat", + "output_cost_per_token": 1.1e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/meta/muse-glimmer-30b:batch": { + "cache_read_input_token_cost": 2e-08, + "input_cost_per_token": 1.75e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 117964, + "max_tokens": 117964, + "mode": "chat", + "output_cost_per_token": 7.5e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/meta/muse-spark-1.1": { + "cache_read_input_token_cost": 1.5e-07, + "input_cost_per_token": 1.25e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 943718, + "max_tokens": 943718, + "mode": "chat", + "output_cost_per_token": 4.25e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/meta/muse-spark-1.2": { + "cache_read_input_token_cost": 1.5e-07, + "input_cost_per_token": 1.25e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 943718, + "max_tokens": 943718, + "mode": "chat", + "output_cost_per_token": 4.25e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/meta/muse-spark-1.2-contributor": { + "cache_read_input_token_cost": 2e-09, + "input_cost_per_token": 1e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 943718, + "max_tokens": 943718, + "mode": "chat", + "output_cost_per_token": 2e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/meta/muse-spark-1.3": { + "cache_read_input_token_cost": 1.5e-07, + "input_cost_per_token": 1.25e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 943718, + "max_tokens": 943718, + "mode": "chat", + "output_cost_per_token": 4.25e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/meta/muse-spark-1.3-contributor": { + "cache_read_input_token_cost": 2e-09, + "input_cost_per_token": 1e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 943718, + "max_tokens": 943718, + "mode": "chat", + "output_cost_per_token": 2e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/microsoft/phi-4": { + "input_cost_per_token": 7e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 16384, + "max_output_tokens": 14745, + "max_tokens": 14745, + "mode": "chat", + "output_cost_per_token": 1.4e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/microsoft/wizardlm-2-8x22b": { + "input_cost_per_token": 6.2e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 65535, + "max_output_tokens": 8000, + "max_tokens": 8000, + "mode": "chat", + "output_cost_per_token": 6.2e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/minimax/minimax-m3:batch": { + "cache_read_input_token_cost": 6e-08, + "input_cost_per_token": 3e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 524288, + "max_output_tokens": 471859, + "max_tokens": 471859, + "mode": "chat", + "output_cost_per_token": 1.2e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/mistralai/codestral-2508:batch": { + "cache_read_input_token_cost": 1.5e-08, + "input_cost_per_token": 1.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 256000, + "max_output_tokens": 204800, + "max_tokens": 204800, + "mode": "chat", + "output_cost_per_token": 4.5e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/mistralai/ministral-8b-2512:batch": { + "cache_read_input_token_cost": 7.5e-09, + "input_cost_per_token": 7.5e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 209715, + "max_tokens": 209715, + "mode": "chat", + "output_cost_per_token": 7.5e-08, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/mistralai/mistral-large-2512:batch": { + "cache_read_input_token_cost": 2.5e-08, + "input_cost_per_token": 2.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 209715, + "max_tokens": 209715, + "mode": "chat", + "output_cost_per_token": 7.5e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/mistralai/mistral-medium-3-5:batch": { + "input_cost_per_token": 7.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 209715, + "max_tokens": 209715, + "mode": "chat", + "output_cost_per_token": 3.75e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/mistralai/mistral-medium-3.1:batch": { + "cache_read_input_token_cost": 2e-08, + "input_cost_per_token": 2e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 104857, + "max_tokens": 104857, + "mode": "chat", + "output_cost_per_token": 1e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/mistralai/mistral-small-2603:batch": { + "cache_read_input_token_cost": 7.5e-09, + "input_cost_per_token": 7.5e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 209715, + "max_tokens": 209715, + "mode": "chat", + "output_cost_per_token": 3e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/moonshotai/kimi-k3:batch": { + "cache_read_input_token_cost": 3e-07, + "input_cost_per_token": 3e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 943718, + "max_tokens": 943718, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/morph/morph-v3-fast": { + "input_cost_per_token": 8e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 81920, + "max_output_tokens": 38000, + "max_tokens": 38000, + "mode": "chat", + "output_cost_per_token": 1.2e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": false, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/morph/morph-v3-large": { + "input_cost_per_token": 9e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 1.9e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/nex-agi/nex-n2.5-mini:free": { + "input_cost_per_token": 0.0, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 235929, + "max_tokens": 235929, + "mode": "chat", + "output_cost_per_token": 0.0, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/nex-agi/nex-n2.5-pro:free": { + "input_cost_per_token": 0.0, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 235929, + "max_tokens": 235929, + "mode": "chat", + "output_cost_per_token": 0.0, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/nousresearch/hermes-3-llama-3.1-405b": { + "input_cost_per_token": 1e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 1e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/nousresearch/hermes-3-llama-3.1-70b": { + "input_cost_per_token": 7e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 7e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/nousresearch/hermes-4-405b": { + "input_cost_per_token": 1e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 117964, + "max_tokens": 117964, + "mode": "chat", + "output_cost_per_token": 3e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/openai/gpt-3.5-turbo-0613": { + "input_cost_per_token": 1e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 4095, + "max_output_tokens": 3685, + "max_tokens": 3685, + "mode": "chat", + "output_cost_per_token": 2e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/openai/gpt-3.5-turbo:batch": { + "input_cost_per_token": 2.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 16385, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 7.5e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/openai/gpt-4-turbo:batch": { + "input_cost_per_token": 5e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/gpt-4.1-mini:batch": { + "cache_read_input_token_cost": 5e-08, + "input_cost_per_token": 2e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1047576, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 8e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/gpt-4.1-nano:batch": { + "cache_read_input_token_cost": 1.25e-08, + "input_cost_per_token": 5e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 1047576, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 2e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/gpt-4.1:batch": { + "cache_read_input_token_cost": 2.5e-07, + "input_cost_per_token": 1e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1047576, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 4e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/gpt-4o-mini:batch": { + "cache_read_input_token_cost": 3.75e-08, + "input_cost_per_token": 7.5e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 3e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "openrouter/openai/gpt-4o:batch": { + "cache_read_input_token_cost": 6.25e-07, + "input_cost_per_token": 1.25e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 5e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "openrouter/openai/gpt-5-image": { + "cache_read_input_token_cost": 1.25e-06, + "input_cost_per_token": 1e-05, + "litellm_provider": "openrouter", + "max_input_tokens": 400000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/gpt-5-image-mini": { + "cache_read_input_token_cost": 2.5e-07, + "input_cost_per_token": 2.5e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 400000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/gpt-5-mini:batch": { + "cache_read_input_token_cost": 1.25e-08, + "input_cost_per_token": 1.25e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 400000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/gpt-5-nano:batch": { + "cache_read_input_token_cost": 2.5e-09, + "input_cost_per_token": 2.5e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 400000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/gpt-5-pro:batch": { + "input_cost_per_token": 7.5e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 400000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 6e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/gpt-5:batch": { + "cache_read_input_token_cost": 6.25e-08, + "input_cost_per_token": 6.25e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 400000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/gpt-5.1:batch": { + "cache_read_input_token_cost": 6.25e-08, + "input_cost_per_token": 6.25e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 400000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/gpt-5.2-pro:batch": { + "input_cost_per_token": 1.05e-05, + "litellm_provider": "openrouter", + "max_input_tokens": 400000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 8.4e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/gpt-5.2:batch": { + "cache_read_input_token_cost": 8.75e-08, + "input_cost_per_token": 8.75e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 400000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 7e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/gpt-5.4-image-2": { + "cache_read_input_token_cost": 2e-06, + "input_cost_per_token": 8e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/gpt-5.4-mini:batch": { + "cache_read_input_token_cost": 3.75e-08, + "input_cost_per_token": 3.75e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 400000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2.25e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/gpt-5.4-nano:batch": { + "cache_read_input_token_cost": 1e-08, + "input_cost_per_token": 1e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 400000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 6.25e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/gpt-5.4-pro:batch": { + "input_cost_per_token": 1.5e-05, + "input_cost_per_token_above_272k_tokens": 3e-05, + "litellm_provider": "openrouter", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 9e-05, + "output_cost_per_token_above_272k_tokens": 0.000135, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/gpt-5.4:batch": { + "cache_read_input_token_cost": 1.25e-07, + "cache_read_input_token_cost_above_272k_tokens": 2.5e-07, + "input_cost_per_token": 1.25e-06, + "input_cost_per_token_above_272k_tokens": 2.5e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 7.5e-06, + "output_cost_per_token_above_272k_tokens": 1.125e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/gpt-5.5-pro:batch": { + "input_cost_per_token": 1.5e-05, + "input_cost_per_token_above_272k_tokens": 3e-05, + "litellm_provider": "openrouter", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 9e-05, + "output_cost_per_token_above_272k_tokens": 0.000135, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/gpt-5.5:batch": { + "cache_read_input_token_cost": 2.5e-07, + "cache_read_input_token_cost_above_272k_tokens": 5e-07, + "input_cost_per_token": 2.5e-06, + "input_cost_per_token_above_272k_tokens": 5e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "output_cost_per_token_above_272k_tokens": 2.25e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/gpt-5.6-luna-pro:batch": { + "cache_read_input_token_cost": 1e-08, + "cache_read_input_token_cost_above_272k_tokens": 2e-08, + "input_cost_per_token": 1e-07, + "input_cost_per_token_above_272k_tokens": 2e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 6e-07, + "output_cost_per_token_above_272k_tokens": 9e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/gpt-5.6-luna:batch": { + "cache_read_input_token_cost": 1e-08, + "cache_read_input_token_cost_above_272k_tokens": 2e-08, + "input_cost_per_token": 1e-07, + "input_cost_per_token_above_272k_tokens": 2e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 6e-07, + "output_cost_per_token_above_272k_tokens": 9e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/gpt-5.6-sol-pro:batch": { + "cache_creation_input_token_cost": 1.25e-06, + "cache_creation_input_token_cost_above_272k_tokens": 2.5e-06, + "cache_read_input_token_cost": 1e-07, + "cache_read_input_token_cost_above_272k_tokens": 2e-07, + "input_cost_per_token": 1e-06, + "input_cost_per_token_above_272k_tokens": 2e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5e-06, + "output_cost_per_token_above_272k_tokens": 7.5e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/gpt-5.6-sol:batch": { + "cache_creation_input_token_cost": 1.25e-06, + "cache_creation_input_token_cost_above_272k_tokens": 2.5e-06, + "cache_read_input_token_cost": 1e-07, + "cache_read_input_token_cost_above_272k_tokens": 2e-07, + "input_cost_per_token": 1e-06, + "input_cost_per_token_above_272k_tokens": 2e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5e-06, + "output_cost_per_token_above_272k_tokens": 7.5e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/gpt-5.6-terra-pro:batch": { + "cache_read_input_token_cost": 1e-07, + "cache_read_input_token_cost_above_272k_tokens": 2e-07, + "input_cost_per_token": 1e-06, + "input_cost_per_token_above_272k_tokens": 2e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 6e-06, + "output_cost_per_token_above_272k_tokens": 9e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/gpt-5.6-terra:batch": { + "cache_read_input_token_cost": 1e-07, + "cache_read_input_token_cost_above_272k_tokens": 2e-07, + "input_cost_per_token": 1e-06, + "input_cost_per_token_above_272k_tokens": 2e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 6e-06, + "output_cost_per_token_above_272k_tokens": 9e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/gpt-6-astra-pro:batch": { + "cache_creation_input_token_cost": 6.25e-06, + "cache_creation_input_token_cost_above_272k_tokens": 1.25e-05, + "cache_read_input_token_cost": 5e-07, + "cache_read_input_token_cost_above_272k_tokens": 1e-06, + "input_cost_per_token": 5e-06, + "input_cost_per_token_above_272k_tokens": 1e-05, + "litellm_provider": "openrouter", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2.5e-05, + "output_cost_per_token_above_272k_tokens": 3.75e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/gpt-6-astra:batch": { + "cache_creation_input_token_cost": 6.25e-06, + "cache_creation_input_token_cost_above_272k_tokens": 1.25e-05, + "cache_read_input_token_cost": 5e-07, + "cache_read_input_token_cost_above_272k_tokens": 1e-06, + "input_cost_per_token": 5e-06, + "input_cost_per_token_above_272k_tokens": 1e-05, + "litellm_provider": "openrouter", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2.5e-05, + "output_cost_per_token_above_272k_tokens": 3.75e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/gpt-oss-120b:batch": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 117964, + "max_tokens": 117964, + "mode": "chat", + "output_cost_per_token": 6e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/openai/o3-mini:batch": { + "cache_read_input_token_cost": 2.75e-07, + "input_cost_per_token": 5.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "chat", + "output_cost_per_token": 2.2e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/openai/o3:batch": { + "cache_read_input_token_cost": 2.5e-07, + "input_cost_per_token": 1e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "chat", + "output_cost_per_token": 4e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/o4-mini:batch": { + "cache_read_input_token_cost": 1.375e-07, + "input_cost_per_token": 5.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "chat", + "output_cost_per_token": 2.2e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/perceptron/perceptron-mk1": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 32768, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.5e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/perplexity/sonar": { + "input_cost_per_token": 1e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 127072, + "max_output_tokens": 114364, + "max_tokens": 114364, + "mode": "chat", + "output_cost_per_token": 1e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": false, + "supports_tool_choice": false, + "supports_vision": true, + "supports_web_search": true + }, + "openrouter/perplexity/sonar-deep-research": { + "input_cost_per_token": 2e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 128000, + "max_output_tokens": 115200, + "max_tokens": 115200, + "mode": "chat", + "output_cost_per_token": 8e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": true + }, + "openrouter/perplexity/sonar-pro": { + "input_cost_per_token": 3e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 200000, + "max_output_tokens": 8000, + "max_tokens": 8000, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": false, + "supports_tool_choice": false, + "supports_vision": true, + "supports_web_search": true + }, + "openrouter/perplexity/sonar-pro-search": { + "input_cost_per_token": 3e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 200000, + "max_output_tokens": 8000, + "max_tokens": 8000, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": true, + "supports_web_search": true + }, + "openrouter/perplexity/sonar-reasoning-pro": { + "input_cost_per_token": 2e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 128000, + "max_output_tokens": 115200, + "max_tokens": 115200, + "mode": "chat", + "output_cost_per_token": 8e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_tool_choice": false, + "supports_vision": true, + "supports_web_search": true + }, + "openrouter/qwen/qwen3.5-9b:batch": { + "input_cost_per_token": 1.7e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 235929, + "max_tokens": 235929, + "mode": "chat", + "output_cost_per_token": 2.5e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/qwen/qwen3.8-2.4t-a95b:batch": { + "cache_read_input_token_cost": 2.5e-07, + "input_cost_per_token": 2e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1010000, + "max_output_tokens": 909000, + "max_tokens": 909000, + "mode": "chat", + "output_cost_per_token": 6e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/qwen/qwen3.8-27b:free": { + "input_cost_per_token": 0.0, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 235929, + "max_tokens": 235929, + "mode": "chat", + "output_cost_per_token": 0.0, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/rekaai/reka-edge": { + "input_cost_per_token": 1e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 16384, + "max_output_tokens": 14745, + "max_tokens": 14745, + "mode": "chat", + "output_cost_per_token": 1e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/rekaai/reka-flash-3": { + "input_cost_per_token": 1e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 65536, + "max_output_tokens": 58982, + "max_tokens": 58982, + "mode": "chat", + "output_cost_per_token": 2e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/relace/relace-apply-3": { + "input_cost_per_token": 8.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 256000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.25e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": false, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/relace/relace-search": { + "input_cost_per_token": 1e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 256000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/sakana/fugu-max": { + "cache_read_input_token_cost": 2.5e-07, + "input_cost_per_token": 2e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 6e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "openrouter/sakana/fugu-ultra": { + "cache_read_input_token_cost": 5e-07, + "cache_read_input_token_cost_above_272k_tokens": 1e-06, + "input_cost_per_token": 5e-06, + "input_cost_per_token_above_272k_tokens": 1e-05, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3e-05, + "output_cost_per_token_above_272k_tokens": 4.5e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "openrouter/sakana/fugu-ultra-v2": { + "cache_read_input_token_cost": 5e-07, + "cache_read_input_token_cost_above_272k_tokens": 1e-06, + "input_cost_per_token": 5e-06, + "input_cost_per_token_above_272k_tokens": 1e-05, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3e-05, + "output_cost_per_token_above_272k_tokens": 4.5e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "openrouter/sakana/sakana-namazu": { + "cache_read_input_token_cost": 1.5e-07, + "input_cost_per_token": 9.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 4e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "openrouter/sao10k/l3-lunaris-8b": { + "input_cost_per_token": 4e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 8192, + "max_output_tokens": 7372, + "max_tokens": 7372, + "mode": "chat", + "output_cost_per_token": 5e-08, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/sao10k/l3.1-euryale-70b": { + "input_cost_per_token": 8.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 8.5e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/sao10k/l3.3-euryale-70b": { + "input_cost_per_token": 6.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 7.5e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/stepfun/step-3.5-flash": { + "input_cost_per_token": 1e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 3e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/stepfun/step-3.7-flash": { + "cache_read_input_token_cost": 4e-08, + "input_cost_per_token": 2e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 230400, + "max_tokens": 230400, + "mode": "chat", + "output_cost_per_token": 1.15e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/tencent/hunyuan-a13b-instruct": { + "input_cost_per_token": 1.4e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 117964, + "max_tokens": 117964, + "mode": "chat", + "output_cost_per_token": 5.7e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/tencent/hy-mt2-1.8b": { + "input_cost_per_token": 4.4e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 8192, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1.77e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": false, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/tencent/hy-mt2-30b-a3b": { + "input_cost_per_token": 7.4e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 8192, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 2.95e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/tencent/hy-mt2-7b": { + "input_cost_per_token": 7.4e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 8192, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 2.95e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/tencent/hy3": { + "cache_read_input_token_cost": 3.3e-08, + "input_cost_per_token": 1.32e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5.28e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/tencent/hy3-preview": { + "cache_read_input_token_cost": 6e-08, + "input_cost_per_token": 1.8e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 235929, + "max_tokens": 235929, + "mode": "chat", + "output_cost_per_token": 6e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/tencent/hy4-preview": { + "cache_read_input_token_cost": 4.2e-08, + "input_cost_per_token": 8.34e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 2.501e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/thedrummer/cydonia-24b-v4.1": { + "cache_read_input_token_cost": 1.5e-07, + "input_cost_per_token": 3e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 117964, + "max_tokens": 117964, + "mode": "chat", + "output_cost_per_token": 5e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/thedrummer/skyfall-36b-v2": { + "cache_read_input_token_cost": 2.5e-07, + "input_cost_per_token": 5.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 32768, + "max_output_tokens": 29491, + "max_tokens": 29491, + "mode": "chat", + "output_cost_per_token": 8e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/thedrummer/unslopnemo-12b": { + "input_cost_per_token": 4e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1024000, + "max_output_tokens": 819200, + "max_tokens": 819200, + "mode": "chat", + "output_cost_per_token": 4e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/thinkingmachines/inkling": { + "cache_read_input_token_cost": 1.7e-07, + "input_cost_per_token": 1e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 471859, + "max_tokens": 471859, + "mode": "chat", + "output_cost_per_token": 4.05e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/thinkingmachines/inkling-small": { + "cache_read_input_token_cost": 1e-07, + "input_cost_per_token": 4.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 1.2e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/thinkingmachines/inkling-small:free": { + "input_cost_per_token": 0.0, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 0.0, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_tool_choice": false, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/thinkingmachines/inkling:batch": { + "cache_read_input_token_cost": 1.7e-07, + "input_cost_per_token": 1e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 524288, + "max_output_tokens": 471859, + "max_tokens": 471859, + "mode": "chat", + "output_cost_per_token": 4.05e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/thinkingmachines/inkling:free": { + "input_cost_per_token": 0.0, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 0.0, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_tool_choice": false, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/unbiased/pareto": { + "cache_read_input_token_cost": 2.5e-07, + "input_cost_per_token": 2.5e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 7.5e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/upstage/solar-pro-3": { + "cache_read_input_token_cost": 1.5e-08, + "input_cost_per_token": 1.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 117964, + "max_tokens": 117964, + "mode": "chat", + "output_cost_per_token": 6e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/upstage/solar-pro4": { + "cache_read_input_token_cost": 1.8e-08, + "input_cost_per_token": 9e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 524288, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 3.6e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/writer/palmyra-x5": { + "input_cost_per_token": 6e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1040000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 6e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": false, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/x-ai/grok-4.3:batch": { + "cache_read_input_token_cost": 1.6e-07, + "cache_read_input_token_cost_above_200k_tokens": 3.2e-07, + "input_cost_per_token": 1e-06, + "input_cost_per_token_above_200k_tokens": 2e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 900000, + "max_tokens": 900000, + "mode": "chat", + "output_cost_per_token": 2e-06, + "output_cost_per_token_above_200k_tokens": 4e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/z-ai/glm-5.2:batch": { + "cache_read_input_token_cost": 7e-08, + "input_cost_per_token": 7e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 943718, + "max_tokens": 943718, + "mode": "chat", + "output_cost_per_token": 2.2e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/z-ai/glm-5.3-flash:batch": { + "cache_read_input_token_cost": 1.5e-08, + "input_cost_per_token": 7.5e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 943718, + "max_tokens": 943718, + "mode": "chat", + "output_cost_per_token": 2.5e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/z-ai/glm-5.3:batch": { + "cache_read_input_token_cost": 1.3e-07, + "input_cost_per_token": 7e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 943718, + "max_tokens": 943718, + "mode": "chat", + "output_cost_per_token": 2.2e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false } } diff --git a/litellm/proxy/config_resolvers/changed_section_keys.py b/litellm/proxy/config_resolvers/changed_section_keys.py new file mode 100644 index 00000000000..d7c2f07bca8 --- /dev/null +++ b/litellm/proxy/config_resolvers/changed_section_keys.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from collections.abc import Mapping +from types import MappingProxyType +from typing import Final + +from pydantic import JsonValue + + +def changed_section_keys( + baseline: Mapping[str, JsonValue], new: Mapping[str, JsonValue] +) -> tuple[Mapping[str, JsonValue], frozenset[str]]: + changed: Final[Mapping[str, JsonValue]] = MappingProxyType( + {key: value for key, value in new.items() if key not in baseline or baseline[key] != value} + ) + removed: Final = frozenset(baseline).difference(new) + return changed, removed diff --git a/litellm/proxy/hooks/prompt_injection_detection.py b/litellm/proxy/hooks/prompt_injection_detection.py index 4eb81a58614..4dcacd11038 100644 --- a/litellm/proxy/hooks/prompt_injection_detection.py +++ b/litellm/proxy/hooks/prompt_injection_detection.py @@ -7,6 +7,8 @@ ## Reject a call if it contains a prompt injection attack. +import asyncio +from concurrent.futures import ThreadPoolExecutor from difflib import SequenceMatcher from typing import Final, Literal @@ -15,7 +17,10 @@ from fastapi import HTTPException import litellm from litellm._logging import verbose_proxy_logger from litellm.caching.caching import DualCache -from litellm.constants import DEFAULT_PROMPT_INJECTION_SIMILARITY_THRESHOLD +from litellm.constants import ( + DEFAULT_PROMPT_INJECTION_SIMILARITY_THRESHOLD, + PROMPT_INJECTION_HEURISTICS_MAX_THREADS, +) from litellm.integrations.custom_logger import CustomLogger from litellm.litellm_core_utils.prompt_templates.factory import ( prompt_injection_detection_default_pt, @@ -24,6 +29,10 @@ from litellm.proxy._types import LiteLLMPromptInjectionParams, UserAPIKeyAuth from litellm.router import Router from litellm.utils import get_formatted_prompt +HEURISTICS_EXECUTOR: Final = ThreadPoolExecutor( + max_workers=PROMPT_INJECTION_HEURISTICS_MAX_THREADS, thread_name_prefix="prompt-injection-heuristics" +) + class _OPTIONAL_PromptInjectionDetection(CustomLogger): enforces_request_content: bool = True @@ -106,6 +115,11 @@ class _OPTIONAL_PromptInjectionDetection(CustomLogger): combinations.append(phrase.lower()) return combinations + async def check_user_input_similarity_off_loop(self, user_input: str) -> bool: + return await asyncio.get_running_loop().run_in_executor( + HEURISTICS_EXECUTOR, self.check_user_input_similarity, user_input + ) + def check_user_input_similarity( self, user_input: str, @@ -167,7 +181,7 @@ class _OPTIONAL_PromptInjectionDetection(CustomLogger): if self.prompt_injection_params is not None: # 1. check if heuristics check turned on if self.prompt_injection_params.heuristics_check is True: - is_prompt_attack = self.check_user_input_similarity(user_input=formatted_prompt) + is_prompt_attack = await self.check_user_input_similarity_off_loop(formatted_prompt) if is_prompt_attack is True: raise HTTPException( status_code=400, @@ -177,7 +191,7 @@ class _OPTIONAL_PromptInjectionDetection(CustomLogger): if self.prompt_injection_params.vector_db_check is True: pass else: - is_prompt_attack = self.check_user_input_similarity(user_input=formatted_prompt) + is_prompt_attack = await self.check_user_input_similarity_off_loop(formatted_prompt) if is_prompt_attack is True: raise HTTPException( diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index d7d8413d2ce..80d9868ff0e 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -27,6 +27,7 @@ from collections.abc import ( Sequence, ) from datetime import datetime, timedelta, timezone +from itertools import chain from types import MappingProxyType, UnionType from typing import ( TYPE_CHECKING, @@ -436,6 +437,7 @@ from litellm.proxy.config_resolvers.alerting import ( MS_TEAMS_DESCRIPTORS, SLACK_DESCRIPTORS, ) +from litellm.proxy.config_resolvers.changed_section_keys import changed_section_keys from litellm.proxy.container_endpoints.endpoints import router as container_router from litellm.proxy.credential_endpoints.endpoints import router as credential_router from litellm.proxy.db.db_transaction_queue.pod_lock_manager import PodLockManager @@ -4757,13 +4759,56 @@ def should_load_db_object(object_type: str | SupportedDBObjectType) -> bool: return any(str(obj) == object_type_str for obj in supported_db_objects) +_CONFIG_PERSISTED_SECTIONS: Final = ("general_settings", "router_settings", "litellm_settings") +_CONFIG_UNMANAGED_EXCLUSIONS: Final = frozenset(("environment_variables", "model_list")) +_CONFIG_SECTION_VALUES: Final = TypeAdapter(Mapping[str, JsonValue]) +_CONFIG_SECTION_LOCK_SQL: Final = "SELECT 1 AS locked FROM pg_advisory_xact_lock(hashtext($1))" + + +class _ConfigParamWhere(TypedDict): + param_name: ReadOnly[str] + + +class _ConfigParamCreate(TypedDict): + param_name: ReadOnly[str] + param_value: ReadOnly[str] + + +class _ConfigParamUpdate(TypedDict): + param_value: ReadOnly[str] + + +class _ConfigParamUpsert(TypedDict): + create: ReadOnly[_ConfigParamCreate] + update: ReadOnly[_ConfigParamUpdate] + + +class _EnvironmentVariablesConfigData(TypedDict): + environment_variables: ReadOnly[object] + + +class _ConfigWithBaseline(dict[str, object]): + def __init__(self, config: Mapping[str, object]) -> None: + super().__init__(config) + self._baseline: Mapping[str, object] = MappingProxyType( + {key: copy.deepcopy(value) for key, value in config.items()} + ) + + @property + def baseline(self) -> Mapping[str, object]: + return self._baseline + + def update_baseline(self, config: Mapping[str, object]) -> None: + self._baseline = MappingProxyType({key: copy.deepcopy(value) for key, value in config.items()}) + + class ProxyConfig: """ Abstraction class on top of config loading/updating logic. Gives us one place to control all config updating logic. """ def __init__(self) -> None: - self.config: dict[str, Any] = {} + self.config: Mapping[str, object] = MappingProxyType({}) self._last_semantic_filter_config: dict[str, object] | None = None self._last_hashicorp_vault_config: dict[str, object] | None = None self._last_cyberark_config: dict[str, object] | None = None # mutable-ok: change-detection cache @@ -4870,50 +4915,130 @@ class ProxyConfig: return await resolve_includes(config=config, location=config_file_path, resolve=resolve, read=read_included) - async def save_config(self, new_config: dict, include_env_vars: bool = False): + async def save_config(self, new_config: Mapping[str, object], include_env_vars: bool = False) -> None: global prisma_client, general_settings, user_config_file_path, store_model_in_db - # Load existing config - ## DB - writes valid config to db - """ - - Do not write restricted params like 'api_key' to the database - - if api_key is passed, save that to the local environment or connected secret manage (maybe expose `litellm.save_secret()`) - """ - if prisma_client is not None and ( general_settings.get("store_model_in_db", False) is True or store_model_in_db ): - # if using - db for config - models are in ModelTable - - # Make a copy to avoid mutating the original config - config_to_save: Final = new_config.copy() - - # environment_variables are persisted to the DB only when a caller - # explicitly opts in. Most callers reach save_config after - # get_config() merged YAML + OS env into new_config (with - # os.environ/ placeholders already resolved to plaintext), so - # persisting them here would snapshot file/container env vars into - # a config row that then shadows those sources on every restart. - # The dedicated /config/update path writes env vars directly, so - # no current caller needs include_env_vars=True. - if not include_env_vars: - config_to_save.pop("environment_variables", None) - - # SECURITY: Always encrypt environment_variables before DB write. - # _encrypt_env_variables_for_db is idempotent — a caller that - # already encrypted the values (or re-submitted ciphertext read - # back from the DB) will not get a stacked second layer. - if "environment_variables" in config_to_save and config_to_save["environment_variables"]: - config_to_save["environment_variables"] = self._encrypt_env_variables_for_db( - environment_variables=config_to_save["environment_variables"] + baseline: Final[Mapping[str, object]] = ( + new_config.baseline if isinstance(new_config, _ConfigWithBaseline) else self.get_config_state() + ) + for section_name in _CONFIG_PERSISTED_SECTIONS: + await self._save_changed_config_section( + section_name=section_name, + baseline=baseline, + new_config=new_config, + prisma_client=prisma_client, ) - config_to_save.pop("model_list", None) - await prisma_client.insert_data(data=config_to_save, table_name="config") - else: - # Save the updated config - if user is not using a dB - ## YAML - with open(f"{user_config_file_path}", "w") as config_file: - yaml.dump(new_config, config_file, default_flow_style=False) + unmanaged_config: Final[Mapping[str, object]] = MappingProxyType( + { + key: value + for key, value in new_config.items() + if key not in _CONFIG_PERSISTED_SECTIONS + and key not in _CONFIG_UNMANAGED_EXCLUSIONS + and (key not in baseline or baseline[key] != value) + } + ) + if unmanaged_config: + await prisma_client.insert_data(data=unmanaged_config, table_name="config") + + environment_variables: Final = new_config.get("environment_variables") + if include_env_vars and environment_variables is not None: + encrypted_environment_variables: Final = ( + self._encrypt_env_variables_for_db(environment_variables=environment_variables) + if isinstance(environment_variables, dict) and environment_variables + else environment_variables + ) + environment_variables_data: Final[_EnvironmentVariablesConfigData] = { + "environment_variables": encrypted_environment_variables + } + await prisma_client.insert_data(data=environment_variables_data, table_name="config") + next_config: Final[Mapping[str, object]] = MappingProxyType({**baseline, **new_config}) + self.update_config_state(config=next_config) + if isinstance(new_config, _ConfigWithBaseline): + new_config.update_baseline(config=next_config) + return + + with open(f"{user_config_file_path}", "w") as config_file: + yaml.dump( + dict(new_config), config_file, default_flow_style=False + ) # mutable-ok: YAML must serialize a plain dict + + async def _save_changed_config_section( + self, + *, + section_name: str, + baseline: Mapping[str, object], + new_config: Mapping[str, object], + prisma_client: PrismaClient, + ) -> None: + if section_name not in new_config: + return + baseline_value: Final = baseline.get(section_name) + new_value: Final = new_config[section_name] + baseline_section: Final[Mapping[str, JsonValue]] = ( + _CONFIG_SECTION_VALUES.validate_python(baseline_value) + if isinstance(baseline_value, Mapping) + else MappingProxyType({}) + ) + new_section: Final[Mapping[str, JsonValue]] = ( + _CONFIG_SECTION_VALUES.validate_python(new_value) + if isinstance(new_value, Mapping) + else MappingProxyType({}) + ) + changed_keys, removed_keys = changed_section_keys(baseline_section, new_section) + if not changed_keys and not removed_keys: + return + wrote_section: Final = await self._upsert_changed_config_section( + section_name=section_name, + changed_keys=changed_keys, + removed_keys=removed_keys, + prisma_client=prisma_client, + ) + if not wrote_section: + return + await invalidate_config_param(section_name) + + async def _upsert_changed_config_section( + self, + *, + section_name: str, + changed_keys: Mapping[str, JsonValue], + removed_keys: frozenset[str], + prisma_client: PrismaClient, + ) -> bool: + async with prisma_client.tx() as tx: + await tx.query_raw(_CONFIG_SECTION_LOCK_SQL, section_name) + config_table: Final = cast("TableActions[_ConfigParamRow]", tx.litellm_config) + config_where: Final[_ConfigParamWhere] = {"param_name": section_name} + existing_row: Final[_ConfigParamRow | None] = await config_table.find_first(where=config_where) + existing_value: Final[object] = cast(object, existing_row.param_value) if existing_row is not None else None + existing_section: Final[Mapping[str, JsonValue]] = ( + _CONFIG_SECTION_VALUES.validate_json(existing_value) + if isinstance(existing_value, str) + else _CONFIG_SECTION_VALUES.validate_python(existing_value) + if isinstance(existing_value, Mapping) + else MappingProxyType({}) + ) + merged_section: Final[Mapping[str, JsonValue]] = MappingProxyType( + { + key: value + for key, value in chain( + ((key, value) for key, value in existing_section.items() if key not in removed_keys), + changed_keys.items(), + ) + } + ) + if merged_section == existing_section: + return False + serialized_section: Final = json.dumps(dict(merged_section)) # mutable-ok: JSON encoder requires a dict + config_data: Final[_ConfigParamUpsert] = { + "create": {"param_name": section_name, "param_value": serialized_section}, + "update": {"param_value": serialized_section}, + } + await config_table.upsert(where=config_where, data=config_data) + return True async def save_environment_variables(self, updates: dict[str, str | None]) -> None: """Persist specific environment variables to the DB config row. @@ -5265,26 +5390,26 @@ class ProxyConfig: self.update_config_state(config=config) - return config + return _ConfigWithBaseline(config) - def update_config_state(self, config: dict): - self.config = config + def update_config_state(self, config: Mapping[str, object]) -> None: + self.config = MappingProxyType({key: copy.deepcopy(value) for key, value in config.items()}) - def get_config_state(self): + def get_config_state(self) -> Mapping[str, object]: """ Returns a deep copy of the config, Do this, to avoid mutating the config state outside of allowed methods """ try: - return copy.deepcopy(self.config) + return MappingProxyType({key: copy.deepcopy(value) for key, value in self.config.items()}) except Exception as e: verbose_proxy_logger.debug( "ProxyConfig:get_config_state(): Error returning copy of config state. self.config=%s\nError: %s", self.config, e, ) - return {} + return MappingProxyType({}) def load_credential_list(self, config: dict) -> list[CredentialItem]: """ diff --git a/litellm/rust_bridge/chat_completions/callbacks.py b/litellm/rust_bridge/chat_completions/route_host.py similarity index 100% rename from litellm/rust_bridge/chat_completions/callbacks.py rename to litellm/rust_bridge/chat_completions/route_host.py diff --git a/litellm/rust_bridge/legacy_callbacks.py b/litellm/rust_bridge/legacy_callbacks.py new file mode 100644 index 00000000000..e05d9368fa8 --- /dev/null +++ b/litellm/rust_bridge/legacy_callbacks.py @@ -0,0 +1,179 @@ +"""The Python half of the legacy callback contract the native call lifecycle drives. + +Everything here is named after the `Logging` object and the sync/async callback +registries it fans out to. It expires with that contract. +""" + +from __future__ import annotations + +import datetime +import os +import uuid +from collections.abc import Mapping +from dataclasses import dataclass +from typing import ( + TYPE_CHECKING, + Final, + Literal, + Protocol, + TypeAlias, + cast, # noqa: TID251 # bounded compatibility calls into legacy Python integrations +) + +from typing_extensions import assert_never + +if TYPE_CHECKING: + from litellm.litellm_core_utils.litellm_logging import Logging + + +class MetadataUpdater(Protocol): + def __call__( + self, + result: object, + logging_obj: Logging, + model: str | None, + kwargs: dict[str, object], + start_time: datetime.datetime, + end_time: datetime.datetime, + ) -> None: ... + + +@dataclass(frozen=True, slots=True) +class CallSetup: + logger: Logging + kwargs: dict[str, object] + bridge_owned: bool + + +def setup( + call_type: str, + args: tuple[object, ...], + kwargs: Mapping[str, object], + start_time: datetime.datetime, + asynchronous: bool, +) -> CallSetup: + from litellm.litellm_core_utils.litellm_logging import Logging + from litellm.utils import Rules, function_setup + + arguments: Final = { # mutable-ok: function_setup consumes an owned kwargs dict + "litellm_call_id": str(uuid.uuid4()), + **kwargs, + } + supplied: Final = arguments.get("litellm_logging_obj") + if isinstance(supplied, Logging): + return CallSetup(supplied, arguments, bridge_owned=False) + logger, prepared = function_setup(call_type, Rules(), start_time, *args, is_async_call=asynchronous, **arguments) + return CallSetup(logger, prepared, bridge_owned=True) + + +def check_limits(kwargs: Mapping[str, object]) -> None: + import litellm + from litellm.litellm_core_utils.core_helpers import max_retries_per_request_hit + + current_cost: Final = litellm._current_cost # pyright: ignore[reportPrivateUsage] # shared SDK budget counter has no public accessor + if litellm.max_budget and current_cost > litellm.max_budget: + raise litellm.BudgetExceededError(current_cost=current_cost, max_budget=litellm.max_budget) + if max_retries_per_request_hit(kwargs, litellm.num_retries_per_request): + raise RuntimeError("Max retries per request hit!") + + +def finalize( + response: object, + logger: Logging, + kwargs: dict[str, object], + start_time: datetime.datetime, + end_time: datetime.datetime, +) -> None: + from litellm.litellm_core_utils.llm_response_utils import response_metadata + + model: Final = kwargs.get("model") + update: Final = cast( # cast-ok: legacy metadata function accepts concrete kwargs + MetadataUpdater, response_metadata.update_response_metadata + ) + update(response, logger, model if isinstance(model, str) else None, kwargs, start_time, end_time) + + +def deployment_callbacks_needed() -> bool: + import litellm + from litellm.integrations.custom_logger import CustomLogger + + return any(isinstance(callback, CustomLogger) for callback in litellm.callbacks) + + +Phase: TypeAlias = Literal[ + "input", "sync_success", "sync_success_async", "async_success", "sync_failure", "async_failure", "payload" +] + + +def callbacks_needed(logger: Logging, phase: Phase) -> bool: + import litellm + from litellm._logging import ( + _is_debugging_on, # pyright: ignore[reportPrivateUsage] # use the same debug gate as Logging + ) + + if ( + _is_debugging_on() + or getattr(logger, "litellm_request_debug", False) + or os.getenv("LITELLM_PRINT_STANDARD_LOGGING_PAYLOAD") + ): + return True + input_needed: Final = bool( + litellm.input_callback + or litellm._async_input_callback # pyright: ignore[reportPrivateUsage] # live async registries have no public accessor + or logger.dynamic_input_callbacks + or callable(getattr(logger, "logger_fn", None)) + or logger.log_raw_request_response + or litellm.log_raw_request_response + ) + match phase: + case "input": + return input_needed + case "sync_success": + return bool(litellm.success_callback or logger.dynamic_success_callbacks) + case "sync_success_async": + return bool( + (litellm.success_callback or logger.dynamic_success_callbacks) + and logger._should_run_sync_callbacks_for_async_calls() # pyright: ignore[reportPrivateUsage] # preserve async call filtering of sync callbacks + ) + case "async_success": + return bool(litellm._async_success_callback or logger.dynamic_async_success_callbacks) # pyright: ignore[reportPrivateUsage] # live async registries have no public accessor + case "sync_failure": + return bool(litellm.failure_callback or logger.dynamic_failure_callbacks) + case "async_failure": + return bool(litellm._async_failure_callback or logger.dynamic_async_failure_callbacks) # pyright: ignore[reportPrivateUsage] # live async registries have no public accessor + case "payload": + return bool( + input_needed + or litellm.success_callback + or litellm.failure_callback + or litellm._async_success_callback # pyright: ignore[reportPrivateUsage] # live async registries have no public accessor + or litellm._async_failure_callback # pyright: ignore[reportPrivateUsage] # live async registries have no public accessor + or logger.dynamic_success_callbacks + or logger.dynamic_async_success_callbacks + or logger.dynamic_failure_callbacks + or logger.dynamic_async_failure_callbacks + ) + case _: + assert_never(phase) + + +def success_bookkeeping( + logger: Logging, response: object, start: datetime.datetime, end: datetime.datetime, asynchronous: bool +) -> None: + phase: Final = "async_success" if asynchronous else "sync_success" + if logger.should_run_logging(phase): + logger._success_handler_helper_fn( # pyright: ignore[reportPrivateUsage] # retain success bookkeeping without constructing a callback payload + result=response, start_time=start, end_time=end, build_logging_payload=False + ) + logger.has_run_logging(phase) + + +def failure_bookkeeping( + logger: Logging, error: BaseException, start: datetime.datetime, end: datetime.datetime, asynchronous: bool +) -> None: + phase: Final = "async_failure" if asynchronous else "sync_failure" + if logger.should_run_logging(phase): + logger._failure_handler_helper_fn( # pyright: ignore[reportPrivateUsage] # retain failure accounting without formatting an unused traceback or payload + error, "", start, end, build_logging_payload=False + ) + logger.has_run_logging(phase) diff --git a/litellm/rust_bridge/lifecycle.py b/litellm/rust_bridge/lifecycle.py index f1cc912129d..d903021b6f3 100644 --- a/litellm/rust_bridge/lifecycle.py +++ b/litellm/rust_bridge/lifecycle.py @@ -1,19 +1,8 @@ from __future__ import annotations -import datetime -import os -import uuid -from collections.abc import Awaitable, Mapping +from collections.abc import Awaitable from dataclasses import dataclass -from typing import ( - TYPE_CHECKING, - Final, - Protocol, - cast, # noqa: TID251 # bounded compatibility calls into legacy Python integrations -) - -if TYPE_CHECKING: - from litellm.litellm_core_utils.litellm_logging import Logging +from typing import Protocol @dataclass(frozen=True, slots=True) @@ -51,155 +40,3 @@ async def drive(execution: Execution) -> object: return step.value finally: execution.close() - - -class MetadataUpdater(Protocol): - def __call__( - self, - result: object, - logging_obj: Logging, - model: str | None, - kwargs: dict[str, object], - start_time: datetime.datetime, - end_time: datetime.datetime, - ) -> None: ... - - -@dataclass(frozen=True, slots=True) -class CallSetup: - logger: Logging - kwargs: dict[str, object] - - -def setup( - call_type: str, - args: tuple[object, ...], - kwargs: Mapping[str, object], - start_time: datetime.datetime, - asynchronous: bool, -) -> CallSetup: - from litellm import utils - from litellm.litellm_core_utils.litellm_logging import Logging - - arguments: Final = { # mutable-ok: function_setup consumes an owned kwargs dict - "litellm_call_id": str(uuid.uuid4()), - **kwargs, - } - supplied: Final = arguments.get("litellm_logging_obj") - if isinstance(supplied, Logging): - supplied._native_callback_fast_path = False # pyright: ignore[reportPrivateUsage] # supplied loggers retain all dispatch contracts - return CallSetup(supplied, arguments) - logger, prepared = utils.function_setup( - call_type, utils.Rules(), start_time, *args, is_async_call=asynchronous, **arguments - ) - if type(logger) is Logging and call_type in ("ocr", "aocr"): - logger._native_callback_fast_path = True # pyright: ignore[reportPrivateUsage] # only bridge-created OCR loggers opt into callback elision - return CallSetup(logger, prepared) - - -def check_limits(kwargs: Mapping[str, object]) -> None: - import litellm - from litellm.litellm_core_utils.core_helpers import max_retries_per_request_hit - - current_cost: Final = litellm._current_cost # pyright: ignore[reportPrivateUsage] # shared SDK budget counter has no public accessor - if litellm.max_budget and current_cost > litellm.max_budget: - raise litellm.BudgetExceededError(current_cost=current_cost, max_budget=litellm.max_budget) - if max_retries_per_request_hit(kwargs, litellm.num_retries_per_request): - raise RuntimeError("Max retries per request hit!") - - -def finalize( - response: object, - logger: Logging, - kwargs: dict[str, object], - start_time: datetime.datetime, - end_time: datetime.datetime, -) -> None: - from litellm.litellm_core_utils.llm_response_utils import response_metadata - - model: Final = kwargs.get("model") - update: Final = cast( # cast-ok: legacy metadata function accepts concrete kwargs - MetadataUpdater, response_metadata.update_response_metadata - ) - update(response, logger, model if isinstance(model, str) else None, kwargs, start_time, end_time) - - -def deployment_callbacks_needed() -> bool: - import litellm - from litellm.integrations.custom_logger import CustomLogger - - return any(isinstance(callback, CustomLogger) for callback in litellm.callbacks) - - -def callbacks_needed(logger: Logging, phase: str) -> bool: - import litellm - from litellm._logging import ( - _is_debugging_on, # pyright: ignore[reportPrivateUsage] # use the same debug gate as Logging - ) - - if ( - _is_debugging_on() - or getattr(logger, "litellm_request_debug", False) - or os.getenv("LITELLM_PRINT_STANDARD_LOGGING_PAYLOAD") - ): - return True - input_needed: Final = bool( - litellm.input_callback - or litellm._async_input_callback # pyright: ignore[reportPrivateUsage] # live async registries have no public accessor - or logger.dynamic_input_callbacks - or callable(getattr(logger, "logger_fn", None)) - or logger.log_raw_request_response - or litellm.log_raw_request_response - ) - match phase: - case "input": - return input_needed - case "sync_success": - return bool(litellm.success_callback or logger.dynamic_success_callbacks) - case "sync_success_async": - return bool( - (litellm.success_callback or logger.dynamic_success_callbacks) - and logger._should_run_sync_callbacks_for_async_calls() # pyright: ignore[reportPrivateUsage] # preserve async call filtering of sync callbacks - ) - case "async_success": - return bool(litellm._async_success_callback or logger.dynamic_async_success_callbacks) # pyright: ignore[reportPrivateUsage] # live async registries have no public accessor - case "sync_failure": - return bool(litellm.failure_callback or logger.dynamic_failure_callbacks) - case "async_failure": - return bool(litellm._async_failure_callback or logger.dynamic_async_failure_callbacks) # pyright: ignore[reportPrivateUsage] # live async registries have no public accessor - case "payload": - return bool( - input_needed - or litellm.success_callback - or litellm.failure_callback - or litellm._async_success_callback # pyright: ignore[reportPrivateUsage] # live async registries have no public accessor - or litellm._async_failure_callback # pyright: ignore[reportPrivateUsage] # live async registries have no public accessor - or logger.dynamic_success_callbacks - or logger.dynamic_async_success_callbacks - or logger.dynamic_failure_callbacks - or logger.dynamic_async_failure_callbacks - ) - case _: - return True - - -def success_bookkeeping( - logger: Logging, response: object, start: datetime.datetime, end: datetime.datetime, asynchronous: bool -) -> None: - phase: Final = "async_success" if asynchronous else "sync_success" - if logger.should_run_logging(phase): - logger._success_handler_helper_fn( # pyright: ignore[reportPrivateUsage] # retain success bookkeeping without constructing a callback payload - result=response, start_time=start, end_time=end, build_logging_payload=False - ) - logger.has_run_logging(phase) - - -def failure_bookkeeping( - logger: Logging, error: BaseException, start: datetime.datetime, end: datetime.datetime, asynchronous: bool -) -> None: - phase: Final = "async_failure" if asynchronous else "sync_failure" - if logger.should_run_logging(phase): - logger._failure_handler_helper_fn( # pyright: ignore[reportPrivateUsage] # retain failure accounting without formatting an unused traceback or payload - error, "", start, end, build_logging_payload=False - ) - logger.has_run_logging(phase) diff --git a/litellm/rust_bridge/messages/callbacks.py b/litellm/rust_bridge/messages/route_host.py similarity index 100% rename from litellm/rust_bridge/messages/callbacks.py rename to litellm/rust_bridge/messages/route_host.py diff --git a/litellm/rust_bridge/ocr/callbacks.py b/litellm/rust_bridge/ocr/route_host.py similarity index 100% rename from litellm/rust_bridge/ocr/callbacks.py rename to litellm/rust_bridge/ocr/route_host.py diff --git a/litellm/rust_bridge/responses/callbacks.py b/litellm/rust_bridge/responses/route_host.py similarity index 100% rename from litellm/rust_bridge/responses/callbacks.py rename to litellm/rust_bridge/responses/route_host.py diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index e903b239881..92df776adf4 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -40682,6 +40682,9 @@ "supports_system_messages": true }, "openrouter/anthropic/claude-3-haiku": { + "cache_creation_input_token_cost": 3e-07, + "cache_creation_input_token_cost_above_1hr": 5e-07, + "cache_read_input_token_cost": 3e-08, "input_cost_per_image": 0.0004, "input_cost_per_token": 2.5e-07, "litellm_provider": "openrouter", @@ -40692,7 +40695,14 @@ "supports_tool_choice": true, "supports_vision": true, "max_input_tokens": 200000, - "max_output_tokens": 4096 + "max_output_tokens": 4096, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": false, + "supports_web_search": false }, "openrouter/anthropic/claude-3.5-sonnet": { "input_cost_per_token": 3e-06, @@ -40727,6 +40737,7 @@ "openrouter/anthropic/claude-opus-4": { "input_cost_per_image": 0.0048, "cache_creation_input_token_cost": 1.875e-05, + "cache_creation_input_token_cost_above_1hr": 3e-05, "cache_read_input_token_cost": 1.5e-06, "input_cost_per_token": 1.5e-05, "litellm_provider": "openrouter", @@ -40742,7 +40753,12 @@ "supports_reasoning": true, "supports_tool_choice": true, "supports_vision": true, - "prompt_cache_min_tokens": 1024 + "prompt_cache_min_tokens": 1024, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_pdf_input": true, + "supports_response_schema": false, + "supports_web_search": false }, "openrouter/anthropic/claude-opus-4.1": { "input_cost_per_image": 0.0048, @@ -40763,11 +40779,17 @@ "supports_reasoning": true, "supports_tool_choice": true, "supports_vision": true, - "prompt_cache_min_tokens": 1024 + "prompt_cache_min_tokens": 1024, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_pdf_input": true, + "supports_response_schema": false, + "supports_web_search": false }, "openrouter/anthropic/claude-sonnet-4": { "input_cost_per_image": 0.0048, "cache_creation_input_token_cost": 3.75e-06, + "cache_creation_input_token_cost_above_1hr": 6e-06, "cache_creation_input_token_cost_above_200k_tokens": 7.5e-06, "cache_read_input_token_cost": 3e-07, "cache_read_input_token_cost_above_200k_tokens": 6e-07, @@ -40787,12 +40809,18 @@ "supports_reasoning": true, "supports_tool_choice": true, "supports_vision": true, - "prompt_cache_min_tokens": 1024 + "prompt_cache_min_tokens": 1024, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_pdf_input": true, + "supports_response_schema": false, + "supports_web_search": false }, "openrouter/anthropic/claude-sonnet-4.6": { "supports_adaptive_thinking": true, "supports_legacy_thinking": true, "cache_creation_input_token_cost": 3.75e-06, + "cache_creation_input_token_cost_above_1hr": 6e-06, "cache_creation_input_token_cost_above_200k_tokens": 7.5e-06, "cache_read_input_token_cost": 3e-07, "cache_read_input_token_cost_above_200k_tokens": 6e-07, @@ -40805,7 +40833,7 @@ "mode": "chat", "output_cost_per_token": 1.5e-05, "output_cost_per_token_above_200k_tokens": 2.25e-05, - "source": "https://openrouter.ai/anthropic/claude-sonnet-4.6", + "source": "https://openrouter.ai/api/v1/models", "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, @@ -40814,10 +40842,15 @@ "supports_max_reasoning_effort": true, "supports_tool_choice": true, "supports_vision": true, - "prompt_cache_min_tokens": 1024 + "prompt_cache_min_tokens": 1024, + "supports_audio_input": false, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_web_search": false }, "openrouter/anthropic/claude-opus-4.5": { "cache_creation_input_token_cost": 6.25e-06, + "cache_creation_input_token_cost_above_1hr": 1e-05, "cache_read_input_token_cost": 5e-07, "input_cost_per_token": 5e-06, "litellm_provider": "openrouter", @@ -40835,12 +40868,17 @@ "supports_vision": true, "supports_output_config": true, "prompt_cache_min_tokens": 4096, - "source": "https://openrouter.ai/anthropic/claude-opus-4.5" + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_web_search": false }, "openrouter/anthropic/claude-opus-4.6": { "supports_adaptive_thinking": true, "supports_legacy_thinking": true, "cache_creation_input_token_cost": 6.25e-06, + "cache_creation_input_token_cost_above_1hr": 1e-05, "cache_read_input_token_cost": 5e-07, "input_cost_per_token": 5e-06, "litellm_provider": "openrouter", @@ -40859,11 +40897,15 @@ "supports_vision": true, "prompt_cache_min_tokens": 4096, "supports_response_schema": true, - "source": "https://openrouter.ai/api/v1/models" + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_pdf_input": true, + "supports_web_search": false }, "openrouter/anthropic/claude-sonnet-4.5": { "input_cost_per_image": 0.0048, "cache_creation_input_token_cost": 3.75e-06, + "cache_creation_input_token_cost_above_1hr": 6e-06, "cache_read_input_token_cost": 3e-07, "input_cost_per_token": 3e-06, "input_cost_per_token_above_200k_tokens": 6e-06, @@ -40871,7 +40913,7 @@ "cache_creation_input_token_cost_above_200k_tokens": 7.5e-06, "cache_read_input_token_cost_above_200k_tokens": 6e-07, "litellm_provider": "openrouter", - "max_input_tokens": 200000, + "max_input_tokens": 1000000, "max_output_tokens": 64000, "max_tokens": 64000, "mode": "chat", @@ -40884,10 +40926,15 @@ "supports_tool_choice": true, "supports_vision": true, "prompt_cache_min_tokens": 1024, - "source": "https://openrouter.ai/anthropic/claude-sonnet-4.5" + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_web_search": false }, "openrouter/anthropic/claude-haiku-4.5": { "cache_creation_input_token_cost": 1.25e-06, + "cache_creation_input_token_cost_above_1hr": 2e-06, "cache_read_input_token_cost": 1e-07, "input_cost_per_token": 1e-06, "litellm_provider": "openrouter", @@ -40904,11 +40951,16 @@ "supports_tool_choice": true, "supports_vision": true, "prompt_cache_min_tokens": 4096, - "source": "https://openrouter.ai/anthropic/claude-haiku-4.5" + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_web_search": false }, "openrouter/anthropic/claude-opus-4.7": { "supports_adaptive_thinking": true, "cache_creation_input_token_cost": 6.25e-06, + "cache_creation_input_token_cost_above_1hr": 1e-05, "cache_read_input_token_cost": 5e-07, "input_cost_per_token": 5e-06, "litellm_provider": "openrouter", @@ -40928,12 +40980,16 @@ "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, - "prompt_cache_min_tokens": 2048 + "prompt_cache_min_tokens": 2048, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_web_search": false }, "openrouter/anthropic/claude-opus-5": { "prompt_cache_min_tokens": 512, "supports_adaptive_thinking": true, "cache_creation_input_token_cost": 6.25e-06, + "cache_creation_input_token_cost_above_1hr": 1e-05, "cache_read_input_token_cost": 5e-07, "input_cost_per_token": 5e-06, "litellm_provider": "openrouter", @@ -40942,8 +40998,9 @@ "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 2.5e-05, - "source": "https://openrouter.ai/anthropic/claude-opus-5", + "source": "https://openrouter.ai/api/v1/models", "supports_assistant_prefill": false, + "supports_audio_input": false, "supports_computer_use": true, "supports_function_calling": true, "supports_pdf_input": true, @@ -40953,49 +41010,74 @@ "supports_max_reasoning_effort": true, "supports_tool_choice": true, "supports_vision": true, + "supports_web_search": false, "supports_xhigh_reasoning_effort": true }, "openrouter/bytedance/ui-tars-1.5-7b": { + "cache_read_input_token_cost": 1e-07, "input_cost_per_token": 1e-07, "litellm_provider": "openrouter", - "max_input_tokens": 131072, + "max_input_tokens": 128000, "max_output_tokens": 2048, "max_tokens": 2048, "mode": "chat", "output_cost_per_token": 2e-07, - "source": "https://openrouter.ai/bytedance/ui-tars-1.5-7b", - "supports_tool_choice": true + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": true, + "supports_web_search": false }, "openrouter/deepseek/deepseek-chat": { - "input_cost_per_token": 2.574e-07, + "input_cost_per_token": 3.2e-07, "litellm_provider": "openrouter", - "max_input_tokens": 65536, - "max_output_tokens": 8192, - "max_tokens": 8192, + "max_input_tokens": 163840, + "max_output_tokens": 16384, + "max_tokens": 16384, "mode": "chat", - "output_cost_per_token": 1.0287e-06, - "supports_prompt_caching": true, + "output_cost_per_token": 8.9e-07, + "supports_prompt_caching": false, "supports_tool_choice": true, - "source": "https://openrouter.ai/api/v1/models" + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_vision": false, + "supports_web_search": false }, "openrouter/deepseek/deepseek-chat-v3-0324": { "input_cost_per_token": 2.5e-07, "litellm_provider": "openrouter", - "max_input_tokens": 65536, - "max_output_tokens": 8192, - "max_tokens": 8192, + "max_input_tokens": 163840, + "max_output_tokens": 147456, + "max_tokens": 147456, "mode": "chat", "output_cost_per_token": 1e-06, - "supports_prompt_caching": true, - "supports_tool_choice": true + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false }, "openrouter/deepseek/deepseek-chat-v3.1": { "input_cost_per_token": 2.5e-07, "input_cost_per_token_cache_hit": 2e-08, "litellm_provider": "openrouter", "max_input_tokens": 163840, - "max_output_tokens": 163840, - "max_tokens": 163840, + "max_output_tokens": 32768, + "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 9.5e-07, "supports_assistant_prefill": true, @@ -41004,9 +41086,15 @@ "supports_reasoning": true, "supports_tool_choice": true, "cache_read_input_token_cost": 1.3e-07, - "source": "https://openrouter.ai/deepseek/deepseek-chat-v3.1" + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_pdf_input": false, + "supports_response_schema": true, + "supports_vision": false, + "supports_web_search": false }, "openrouter/deepseek/deepseek-v3.2": { + "cache_read_input_token_cost": 1.345e-07, "input_cost_per_token": 2.69e-07, "input_cost_per_token_cache_hit": 1.345e-07, "litellm_provider": "openrouter", @@ -41021,69 +41109,96 @@ "supports_reasoning": true, "supports_tool_choice": true, "supports_response_schema": true, - "source": "https://openrouter.ai/api/v1/models" + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_pdf_input": false, + "supports_vision": false, + "supports_web_search": false }, "openrouter/deepseek/deepseek-v3.2-exp": { "input_cost_per_token": 2.7e-07, "input_cost_per_token_cache_hit": 2e-08, "litellm_provider": "openrouter", "max_input_tokens": 163840, - "max_output_tokens": 163840, - "max_tokens": 163840, + "max_output_tokens": 65536, + "max_tokens": 65536, "mode": "chat", "output_cost_per_token": 4.1e-07, + "source": "https://openrouter.ai/api/v1/models", "supports_assistant_prefill": true, + "supports_audio_input": false, "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_reasoning": false, - "supports_tool_choice": true + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false }, "openrouter/deepseek/deepseek-r1": { "input_cost_per_token": 7e-07, "input_cost_per_token_cache_hit": 1.4e-07, "litellm_provider": "openrouter", - "max_input_tokens": 65336, - "max_output_tokens": 8192, - "max_tokens": 8192, + "max_input_tokens": 64000, + "max_output_tokens": 16000, + "max_tokens": 16000, "mode": "chat", "output_cost_per_token": 2.5e-06, + "source": "https://openrouter.ai/api/v1/models", "supports_assistant_prefill": true, + "supports_audio_input": false, "supports_function_calling": true, - "supports_prompt_caching": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_reasoning": true, - "supports_tool_choice": true + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false }, "openrouter/deepseek/deepseek-r1-0528": { + "cache_read_input_token_cost": 3.5e-07, "input_cost_per_token": 5e-07, "input_cost_per_token_cache_hit": 1.4e-07, "litellm_provider": "openrouter", - "max_input_tokens": 65336, - "max_output_tokens": 8192, - "max_tokens": 8192, + "max_input_tokens": 163840, + "max_output_tokens": 32768, + "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 2.15e-06, + "source": "https://openrouter.ai/api/v1/models", "supports_assistant_prefill": true, + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_prompt_caching": true, "supports_reasoning": true, - "supports_tool_choice": true + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false }, "openrouter/deepseek/deepseek-v4-pro": { - "input_cost_per_token": 1.6e-06, + "input_cost_per_token": 9.4336e-07, "input_cost_per_token_cache_hit": 4.4e-08, "litellm_provider": "openrouter", "max_input_tokens": 1048576, - "max_output_tokens": 384000, - "max_tokens": 384000, + "max_output_tokens": 393216, + "max_tokens": 393216, "mode": "chat", - "output_cost_per_token": 3.2e-06, - "source": "https://openrouter.ai/deepseek/deepseek-v4-pro", + "output_cost_per_token": 1.88672e-06, + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "cache_read_input_token_cost": 1.35e-07 + "cache_read_input_token_cost": 7.9596e-08, + "supports_audio_input": false, + "supports_pdf_input": false, + "supports_vision": false, + "supports_web_search": false }, "openrouter/deepseek/deepseek-v4.1-flash": { "input_cost_per_token": 1.5e-07, @@ -41094,31 +41209,37 @@ "max_output_tokens": 384000, "max_tokens": 384000, "mode": "chat", - "source": "https://openrouter.ai/deepseek/deepseek-v4.1-flash", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, "supports_pdf_input": false, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/deepseek/deepseek-v4-pro-0813": { - "input_cost_per_token": 5.7948e-07, + "input_cost_per_token": 6.6e-07, "input_cost_per_token_cache_hit": 4.4e-08, "litellm_provider": "openrouter", "max_input_tokens": 1048576, - "max_output_tokens": 384000, - "max_tokens": 384000, + "max_output_tokens": 393216, + "max_tokens": 393216, "mode": "chat", - "output_cost_per_token": 1.73844e-06, - "source": "https://openrouter.ai/deepseek/deepseek-v4-pro-0813", + "output_cost_per_token": 1.98e-06, + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "cache_read_input_token_cost": 1.9316e-08 + "cache_read_input_token_cost": 2.2e-08, + "supports_audio_input": false, + "supports_pdf_input": false, + "supports_vision": false, + "supports_web_search": false }, "openrouter/google/gemini-2.0-flash-001": { "deprecation_date": "2026-06-01", @@ -41138,7 +41259,9 @@ "supports_vision": true }, "openrouter/google/gemini-2.5-flash": { - "input_cost_per_audio_token": 7e-07, + "cache_creation_input_token_cost": 8.33333333333333e-08, + "cache_read_input_audio_token_cost": 1e-07, + "input_cost_per_audio_token": 1e-06, "input_cost_per_token": 3e-07, "litellm_provider": "openrouter", "max_input_tokens": 1048576, @@ -41155,15 +41278,21 @@ "supports_image_size": false, "cache_read_input_token_cost": 3e-08, "supports_prompt_caching": true, - "source": "https://openrouter.ai/google/gemini-2.5-flash" + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": true, + "supports_pdf_input": true, + "supports_reasoning": true, + "supports_web_search": false }, "openrouter/google/gemini-2.5-pro": { - "input_cost_per_audio_token": 7e-07, + "cache_creation_input_token_cost": 3.75e-07, + "cache_read_input_audio_token_cost": 1.25e-07, + "input_cost_per_audio_token": 1.25e-06, "input_cost_per_token": 1.25e-06, "litellm_provider": "openrouter", "max_input_tokens": 1048576, - "max_output_tokens": 65535, - "max_tokens": 65535, + "max_output_tokens": 65536, + "max_tokens": 65536, "mode": "chat", "output_cost_per_token": 1e-05, "supports_audio_output": true, @@ -41173,8 +41302,15 @@ "supports_tool_choice": true, "supports_vision": true, "cache_read_input_token_cost": 1.25e-07, + "cache_read_input_token_cost_above_200k_tokens": 2.5e-07, + "input_cost_per_token_above_200k_tokens": 2.5e-06, + "output_cost_per_token_above_200k_tokens": 1.5e-05, "supports_prompt_caching": true, - "source": "https://openrouter.ai/google/gemini-2.5-pro" + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": true, + "supports_pdf_input": true, + "supports_reasoning": true, + "supports_web_search": false }, "openrouter/google/gemini-3-pro-preview": { "cache_read_input_token_cost": 2e-07, @@ -41218,18 +41354,20 @@ "supports_web_search": true }, "openrouter/google/gemini-3-flash-preview": { + "cache_creation_input_token_cost": 8.33333333333333e-08, + "cache_read_input_audio_token_cost": 1e-07, "cache_read_input_token_cost": 5e-08, "input_cost_per_audio_token": 1e-06, "input_cost_per_token": 5e-07, "litellm_provider": "openrouter", "max_input_tokens": 1048576, - "max_output_tokens": 65535, - "max_tokens": 65535, + "max_output_tokens": 65536, + "max_tokens": 65536, "mode": "chat", "output_cost_per_reasoning_token": 3e-06, "output_cost_per_token": 3e-06, "rpm": 2000, - "source": "https://ai.google.dev/gemini-api/docs/pricing", + "source": "https://openrouter.ai/api/v1/models", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -41244,6 +41382,7 @@ "supported_output_modalities": [ "text" ], + "supports_audio_input": true, "supports_audio_output": false, "supports_function_calling": true, "supports_parallel_function_calling": true, @@ -41255,10 +41394,12 @@ "supports_tool_choice": true, "supports_url_context": true, "supports_vision": true, - "supports_web_search": true, + "supports_web_search": false, "tpm": 800000 }, "openrouter/google/gemini-3.1-flash-lite-preview": { + "cache_creation_input_token_cost": 8.33333333333333e-08, + "cache_read_input_audio_token_cost": 5e-08, "cache_read_input_token_cost": 2.5e-08, "input_cost_per_audio_token": 5e-07, "input_cost_per_token": 2.5e-07, @@ -41270,7 +41411,7 @@ "output_cost_per_reasoning_token": 1.5e-06, "output_cost_per_token": 1.5e-06, "rpm": 2000, - "source": "https://ai.google.dev/gemini-api/docs/pricing", + "source": "https://openrouter.ai/api/v1/models", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -41298,10 +41439,12 @@ "supports_url_context": true, "supports_video_input": true, "supports_vision": true, - "supports_web_search": true, + "supports_web_search": false, "tpm": 800000 }, "openrouter/google/gemini-3.1-flash-lite": { + "cache_creation_input_token_cost": 8.33333333333333e-08, + "cache_read_input_audio_token_cost": 5e-08, "cache_read_input_token_cost": 2.5e-08, "input_cost_per_audio_token": 5e-07, "input_cost_per_token": 2.5e-07, @@ -41313,7 +41456,7 @@ "output_cost_per_reasoning_token": 1.5e-06, "output_cost_per_token": 1.5e-06, "rpm": 2000, - "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-3.1-flash-lite", + "source": "https://openrouter.ai/api/v1/models", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -41341,13 +41484,16 @@ "supports_url_context": true, "supports_video_input": true, "supports_vision": true, - "supports_web_search": true, + "supports_web_search": false, "tpm": 800000 }, "openrouter/google/gemini-3.1-pro-preview": { + "cache_creation_input_token_cost": 3.75e-07, "cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_above_200k_tokens": 4e-07, "cache_creation_input_token_cost_above_200k_tokens": 2.5e-07, + "cache_read_input_audio_token_cost": 2e-07, + "input_cost_per_audio_token": 2e-06, "input_cost_per_token": 2e-06, "input_cost_per_token_above_200k_tokens": 4e-06, "litellm_provider": "openrouter", @@ -41357,7 +41503,7 @@ "mode": "chat", "output_cost_per_token": 1.2e-05, "output_cost_per_token_above_200k_tokens": 1.8e-05, - "source": "https://openrouter.ai/google/gemini-3.1-pro-preview", + "source": "https://openrouter.ai/api/v1/models", "supported_modalities": [ "text", "image", @@ -41375,26 +41521,46 @@ "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/gryphe/mythomax-l2-13b": { "input_cost_per_token": 8e-08, "litellm_provider": "openrouter", - "max_tokens": 8192, + "max_input_tokens": 8192, + "max_output_tokens": 3686, + "max_tokens": 3686, "mode": "chat", "output_cost_per_token": 1.1e-07, - "supports_tool_choice": true, - "source": "https://openrouter.ai/api/v1/models" + "supports_tool_choice": false, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_vision": false, + "supports_web_search": false }, "openrouter/mancer/weaver": { "input_cost_per_token": 4e-07, "litellm_provider": "openrouter", - "max_tokens": 2000, + "max_tokens": 6000, "mode": "chat", "output_cost_per_token": 7.5e-07, - "supports_tool_choice": true, + "supports_tool_choice": false, "max_input_tokens": 8000, - "max_output_tokens": 2000 + "max_output_tokens": 6000, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_vision": false, + "supports_web_search": false }, "openrouter/meta-llama/llama-3-70b-instruct": { "input_cost_per_token": 5.9e-07, @@ -41410,84 +41576,125 @@ "input_cost_per_token": 2.55e-07, "litellm_provider": "openrouter", "max_input_tokens": 204800, - "max_output_tokens": 204800, - "max_tokens": 204800, + "max_output_tokens": 131072, + "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 1.02e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, - "supports_prompt_caching": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_reasoning": true, - "supports_tool_choice": true + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false }, "openrouter/mistralai/devstral-2512": { + "cache_read_input_token_cost": 4e-08, "input_cost_per_image": 0, "input_cost_per_token": 4e-07, "litellm_provider": "openrouter", "max_input_tokens": 262144, - "max_output_tokens": 65536, - "max_tokens": 65536, + "max_output_tokens": 209715, + "max_tokens": 209715, "mode": "chat", "output_cost_per_token": 2e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, - "supports_prompt_caching": false, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": false + "supports_vision": false, + "supports_web_search": false }, "openrouter/mistralai/ministral-3b-2512": { + "cache_read_input_token_cost": 1e-08, "input_cost_per_image": 0, "input_cost_per_token": 1e-07, "litellm_provider": "openrouter", "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, + "max_output_tokens": 104857, + "max_tokens": 104857, "mode": "chat", "output_cost_per_token": 1e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, - "supports_prompt_caching": false, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/mistralai/ministral-8b-2512": { + "cache_read_input_token_cost": 1.5e-08, "input_cost_per_image": 0, "input_cost_per_token": 1.5e-07, "litellm_provider": "openrouter", "max_input_tokens": 262144, - "max_output_tokens": 262144, - "max_tokens": 262144, + "max_output_tokens": 209715, + "max_tokens": 209715, "mode": "chat", "output_cost_per_token": 1.5e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, - "supports_prompt_caching": false, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/mistralai/ministral-14b-2512": { + "cache_read_input_token_cost": 2e-08, "input_cost_per_image": 0, "input_cost_per_token": 2e-07, "litellm_provider": "openrouter", "max_input_tokens": 262144, - "max_output_tokens": 262144, - "max_tokens": 262144, + "max_output_tokens": 209715, + "max_tokens": 209715, "mode": "chat", "output_cost_per_token": 2e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, - "supports_prompt_caching": false, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/mistralai/mistral-large-2512": { + "cache_read_input_token_cost": 5.5e-08, "input_cost_per_image": 0, - "input_cost_per_token": 5e-07, + "input_cost_per_token": 5.5e-07, "litellm_provider": "openrouter", "max_input_tokens": 262144, - "max_output_tokens": 262144, - "max_tokens": 262144, + "max_output_tokens": 209715, + "max_tokens": 209715, "mode": "chat", - "output_cost_per_token": 1.5e-06, + "output_cost_per_token": 1.65e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, - "supports_prompt_caching": false, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/mistralai/mistral-7b-instruct": { "input_cost_per_token": 1.3e-07, @@ -41500,71 +41707,123 @@ "max_output_tokens": 8191 }, "openrouter/mistralai/mistral-large": { + "cache_read_input_token_cost": 2e-07, "input_cost_per_token": 2e-06, "litellm_provider": "openrouter", - "max_tokens": 8191, + "max_tokens": 102400, "mode": "chat", "output_cost_per_token": 6e-06, "supports_tool_choice": true, "max_input_tokens": 128000, - "max_output_tokens": 8191 + "max_output_tokens": 102400, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_vision": false, + "supports_web_search": false }, "openrouter/mistralai/mistral-small-3.1-24b-instruct": { "input_cost_per_token": 3.51e-07, "litellm_provider": "openrouter", - "max_tokens": 131072, + "max_tokens": 102400, "mode": "chat", "output_cost_per_token": 5.55e-07, - "supports_tool_choice": true, - "max_input_tokens": 131072, - "max_output_tokens": 131072 + "supports_tool_choice": false, + "max_input_tokens": 128000, + "max_output_tokens": 102400, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": false, + "supports_vision": true, + "supports_web_search": false }, "openrouter/mistralai/mistral-small-3.2-24b-instruct": { "input_cost_per_token": 9.375e-08, "litellm_provider": "openrouter", - "max_tokens": 128000, + "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 2.5e-07, "supports_tool_choice": true, - "max_input_tokens": 128000, - "max_output_tokens": 128000, - "source": "https://openrouter.ai/api/v1/models" + "max_input_tokens": 256000, + "max_output_tokens": 16384, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_vision": true, + "supports_web_search": false }, "openrouter/mistralai/mixtral-8x22b-instruct": { + "cache_read_input_token_cost": 2e-07, "input_cost_per_token": 2e-06, "litellm_provider": "openrouter", - "max_tokens": 65536, + "max_tokens": 52428, "mode": "chat", "output_cost_per_token": 6e-06, "supports_tool_choice": true, "max_input_tokens": 65536, - "max_output_tokens": 65536 + "max_output_tokens": 52428, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_vision": false, + "supports_web_search": false }, "openrouter/moonshotai/kimi-k2.5": { "cache_read_input_token_cost": 7e-08, "input_cost_per_token": 4.5e-07, "litellm_provider": "openrouter", "max_input_tokens": 262144, - "max_output_tokens": 262144, - "max_tokens": 262144, + "max_output_tokens": 235929, + "max_tokens": 235929, "mode": "chat", "output_cost_per_token": 2.25e-06, - "source": "https://openrouter.ai/moonshotai/kimi-k2.5", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, "supports_tool_choice": true, "supports_video_input": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/nvidia/nemotron-3.5-lightning": { + "cache_read_input_token_cost": 4e-08, "input_cost_per_token": 8e-08, "litellm_provider": "openrouter", "max_input_tokens": 262144, + "max_output_tokens": 131072, + "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 2e-07, - "source": "https://openrouter.ai/nvidia/nemotron-3.5-lightning", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, "supports_reasoning": true, - "supports_tool_choice": true + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false }, "openrouter/openai/gpt-3.5-turbo": { "input_cost_per_token": 5e-07, @@ -41575,7 +41834,15 @@ "supports_tool_choice": true, "max_input_tokens": 16385, "max_output_tokens": 4096, - "source": "https://openrouter.ai/openai/gpt-3.5-turbo" + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_vision": false, + "supports_web_search": false }, "openrouter/openai/gpt-3.5-turbo-16k": { "input_cost_per_token": 3e-06, @@ -41585,7 +41852,16 @@ "output_cost_per_token": 4e-06, "supports_tool_choice": true, "max_input_tokens": 16385, - "max_output_tokens": 4096 + "max_output_tokens": 4096, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_vision": false, + "supports_web_search": false }, "openrouter/openai/gpt-4": { "input_cost_per_token": 3e-05, @@ -41595,7 +41871,16 @@ "output_cost_per_token": 6e-05, "supports_tool_choice": true, "max_input_tokens": 8191, - "max_output_tokens": 4096 + "max_output_tokens": 4096, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_vision": false, + "supports_web_search": false }, "openrouter/openai/gpt-4.1": { "cache_read_input_token_cost": 5e-07, @@ -41606,13 +41891,18 @@ "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 8e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_pdf_input": true, "supports_prompt_caching": true, + "supports_reasoning": false, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/openai/gpt-4.1-mini": { "cache_read_input_token_cost": 1e-07, @@ -41623,13 +41913,18 @@ "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 1.6e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_pdf_input": true, "supports_prompt_caching": true, + "supports_reasoning": false, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/openai/gpt-4.1-nano": { "cache_read_input_token_cost": 2.5e-08, @@ -41640,13 +41935,18 @@ "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 4e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_pdf_input": true, "supports_prompt_caching": true, + "supports_reasoning": false, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/openai/gpt-4o": { "input_cost_per_token": 2.5e-06, @@ -41662,7 +41962,12 @@ "supports_vision": true, "cache_read_input_token_cost": 1.25e-06, "supports_prompt_caching": true, - "source": "https://openrouter.ai/openai/gpt-4o" + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_pdf_input": true, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_web_search": true }, "openrouter/openai/gpt-4o-2024-05-13": { "input_cost_per_token": 5e-06, @@ -41672,10 +41977,17 @@ "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1.5e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": true }, "openrouter/openai/gpt-5-chat": { "cache_read_input_token_cost": 1.25e-07, @@ -41719,11 +42031,12 @@ "cache_read_input_token_cost": 1.75e-07, "input_cost_per_token": 1.75e-06, "litellm_provider": "openrouter", - "max_input_tokens": 272000, + "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1.4e-05, + "source": "https://openrouter.ai/api/v1/models", "supported_modalities": [ "text", "image" @@ -41731,18 +42044,26 @@ "supported_output_modalities": [ "text" ], + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, "supports_reasoning": true, - "supports_tool_choice": true + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false }, "openrouter/openai/gpt-5": { "cache_read_input_token_cost": 1.25e-07, "input_cost_per_token": 1.25e-06, "litellm_provider": "openrouter", - "max_input_tokens": 272000, + "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1e-05, + "source": "https://openrouter.ai/api/v1/models", "supported_modalities": [ "text", "image" @@ -41750,18 +42071,26 @@ "supported_output_modalities": [ "text" ], + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, "supports_reasoning": true, - "supports_tool_choice": true + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false }, "openrouter/openai/gpt-5-mini": { "cache_read_input_token_cost": 2.5e-08, "input_cost_per_token": 2.5e-07, "litellm_provider": "openrouter", - "max_input_tokens": 272000, + "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 2e-06, + "source": "https://openrouter.ai/api/v1/models", "supported_modalities": [ "text", "image" @@ -41769,18 +42098,26 @@ "supported_output_modalities": [ "text" ], + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, "supports_reasoning": true, - "supports_tool_choice": true + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false }, "openrouter/openai/gpt-5-nano": { "cache_read_input_token_cost": 5e-09, "input_cost_per_token": 5e-08, "litellm_provider": "openrouter", - "max_input_tokens": 272000, + "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 4e-07, + "source": "https://openrouter.ai/api/v1/models", "supported_modalities": [ "text", "image" @@ -41788,8 +42125,15 @@ "supported_output_modalities": [ "text" ], + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, "supports_reasoning": true, - "supports_tool_choice": true + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false }, "openrouter/openai/gpt-5.1-codex-max": { "cache_read_input_token_cost": 1.25e-07, @@ -41800,7 +42144,7 @@ "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1e-05, - "source": "https://openrouter.ai/openai/gpt-5.1-codex-max", + "source": "https://openrouter.ai/api/v1/models", "supported_modalities": [ "text", "image" @@ -41808,27 +42152,36 @@ "supported_output_modalities": [ "text" ], + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_prompt_caching": true, "supports_reasoning": true, + "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/openai/gpt-5.2": { "input_cost_per_image": 0, "cache_read_input_token_cost": 1.75e-07, "input_cost_per_token": 1.75e-06, "litellm_provider": "openrouter", - "max_input_tokens": 272000, + "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1.4e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, + "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/openai/gpt-5.2-chat": { "input_cost_per_image": 0, @@ -41836,29 +42189,40 @@ "input_cost_per_token": 1.75e-06, "litellm_provider": "openrouter", "max_input_tokens": 128000, - "max_output_tokens": 16384, - "max_tokens": 16384, + "max_output_tokens": 32000, + "max_tokens": 32000, "mode": "chat", "output_cost_per_token": 1.4e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": true, "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/openai/gpt-5.2-pro": { "input_cost_per_image": 0, "input_cost_per_token": 2.1e-05, "litellm_provider": "openrouter", - "max_input_tokens": 272000, + "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 0.000168, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": true, "supports_prompt_caching": false, "supports_reasoning": true, + "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/openai/gpt-5.6-sol": { "cache_creation_input_token_cost": 2.5e-06, @@ -41883,7 +42247,7 @@ "xhigh", "max" ], - "source": "https://openrouter.ai/openai/gpt-5.6-sol", + "source": "https://openrouter.ai/api/v1/models", "supported_modalities": [ "text", "image" @@ -41891,19 +42255,22 @@ "supported_output_modalities": [ "text" ], + "supports_audio_input": false, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/openai/gpt-5.6-sol-pro": { "input_cost_per_token": 2e-06, "output_cost_per_token": 1e-05, "cache_read_input_token_cost": 2e-07, "cache_creation_input_token_cost": 2.5e-06, + "cache_creation_input_token_cost_above_272k_tokens": 5e-06, "input_cost_per_token_above_272k_tokens": 4e-06, "output_cost_per_token_above_272k_tokens": 1.5e-05, "cache_read_input_token_cost_above_272k_tokens": 4e-07, @@ -41912,44 +42279,58 @@ "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "source": "https://openrouter.ai/openai/gpt-5.6-sol-pro", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, "supports_pdf_input": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/openai/gpt-oss-120b": { - "input_cost_per_token": 3.7e-08, + "cache_read_input_token_cost": 7.5e-08, + "input_cost_per_token": 1.5e-07, "litellm_provider": "openrouter", "max_input_tokens": 131072, - "max_output_tokens": 32768, - "max_tokens": 32768, + "max_output_tokens": 117964, + "max_tokens": 117964, "mode": "chat", - "output_cost_per_token": 1.7e-07, - "source": "https://openrouter.ai/openai/gpt-oss-120b", + "output_cost_per_token": 6e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_reasoning": true, "supports_response_schema": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false }, "openrouter/openai/gpt-oss-20b": { + "cache_read_input_token_cost": 3e-08, "input_cost_per_token": 3e-08, "litellm_provider": "openrouter", "max_input_tokens": 131072, - "max_output_tokens": 32768, - "max_tokens": 32768, + "max_output_tokens": 117964, + "max_tokens": 117964, "mode": "chat", "output_cost_per_token": 1.3e-07, - "source": "https://openrouter.ai/openai/gpt-oss-20b", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false }, "openrouter/openai/o1": { "cache_read_input_token_cost": 7.5e-06, @@ -41960,13 +42341,18 @@ "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 6e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_pdf_input": true, "supports_prompt_caching": true, + "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/openai/o3-mini": { "input_cost_per_token": 1.1e-06, @@ -41983,7 +42369,11 @@ "supports_vision": false, "cache_read_input_token_cost": 5.5e-07, "supports_prompt_caching": true, - "source": "https://openrouter.ai/openai/o3-mini" + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_web_search": false }, "openrouter/openai/o3-mini-high": { "input_cost_per_token": 1.1e-06, @@ -42000,17 +42390,30 @@ "supports_vision": false, "cache_read_input_token_cost": 5.5e-07, "supports_prompt_caching": true, - "source": "https://openrouter.ai/openai/o3-mini-high" + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_web_search": false }, "openrouter/qwen/qwen-2.5-coder-32b-instruct": { "input_cost_per_token": 6.6e-07, "litellm_provider": "openrouter", - "max_input_tokens": 33792, - "max_output_tokens": 33792, - "max_tokens": 33792, + "max_input_tokens": 32768, + "max_output_tokens": 29491, + "max_tokens": 29491, "mode": "chat", "output_cost_per_token": 1e-06, - "supports_tool_choice": true + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": false, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false }, "openrouter/qwen/qwen-vl-plus": { "input_cost_per_token": 2.1e-07, @@ -42024,56 +42427,89 @@ "supports_vision": true }, "openrouter/qwen/qwen3-coder": { + "cache_read_input_token_cost": 1e-07, "input_cost_per_token": 3e-07, "litellm_provider": "openrouter", - "max_input_tokens": 262100, - "max_output_tokens": 262100, - "max_tokens": 262100, + "max_input_tokens": 262144, + "max_output_tokens": 65536, + "max_tokens": 65536, "mode": "chat", "output_cost_per_token": 1e-06, - "source": "https://openrouter.ai/qwen/qwen3-coder", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_tool_choice": true, - "supports_function_calling": true + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_vision": false, + "supports_web_search": false }, "openrouter/qwen/qwen3-coder-plus": { + "cache_creation_input_token_cost": 8.125e-07, + "cache_read_input_token_cost": 1.3e-07, "input_cost_per_token": 6.5e-07, + "input_cost_per_token_above_128k_tokens": 1.95e-06, "litellm_provider": "openrouter", - "max_input_tokens": 997952, + "max_input_tokens": 1000000, "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", "output_cost_per_token": 3.25e-06, - "source": "https://openrouter.ai/qwen/qwen3-coder-plus", + "output_cost_per_token_above_128k_tokens": 9.75e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, - "supports_reasoning": true, - "supports_tool_choice": true + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false }, "openrouter/qwen/qwen3-235b-a22b-2507": { + "cache_read_input_token_cost": 1.75e-08, "input_cost_per_token": 8.75e-08, "litellm_provider": "openrouter", "max_input_tokens": 262144, - "max_output_tokens": 262144, - "max_tokens": 262144, + "max_output_tokens": 235929, + "max_tokens": 235929, "mode": "chat", "output_cost_per_token": 3.5e-07, - "source": "https://openrouter.ai/qwen/qwen3-235b-a22b-2507", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, - "supports_tool_choice": true + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false }, "openrouter/qwen/qwen3-235b-a22b-thinking-2507": { "input_cost_per_token": 2.3e-07, "litellm_provider": "openrouter", - "max_input_tokens": 262144, - "max_output_tokens": 262144, - "max_tokens": 262144, + "max_input_tokens": 131072, + "max_output_tokens": 117964, + "max_tokens": 117964, "mode": "chat", "output_cost_per_token": 2.3e-06, - "source": "https://openrouter.ai/qwen/qwen3-235b-a22b-thinking-2507", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_reasoning": true, - "supports_tool_choice": true + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false }, "openrouter/qwen/qwen3.6-plus": { + "cache_creation_input_token_cost": 4.0625e-07, "input_cost_per_token": 3.25e-07, "litellm_provider": "openrouter", "max_input_tokens": 1000000, @@ -42081,11 +42517,16 @@ "max_tokens": 65536, "mode": "chat", "output_cost_per_token": 1.95e-06, - "source": "https://openrouter.ai/qwen/qwen3.6-plus", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_reasoning": true, + "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/qwen/qwen3.5-35b-a3b": { "input_cost_per_token": 1.625e-07, @@ -42095,12 +42536,17 @@ "max_tokens": 65536, "mode": "chat", "output_cost_per_token": 1.3e-06, - "source": "https://openrouter.ai/qwen/qwen3.5-35b-a3b", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true, "supports_vision": true, - "cache_read_input_token_cost": 1.5625e-07 + "cache_read_input_token_cost": 1.5625e-07, + "supports_audio_input": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_response_schema": true, + "supports_web_search": false }, "openrouter/qwen/qwen3.5-27b": { "input_cost_per_token": 1.95e-07, @@ -42110,11 +42556,16 @@ "max_tokens": 65536, "mode": "chat", "output_cost_per_token": 1.56e-06, - "source": "https://openrouter.ai/qwen/qwen3.5-27b", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_reasoning": true, + "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/qwen/qwen3.5-122b-a10b": { "input_cost_per_token": 2.6e-07, @@ -42124,11 +42575,16 @@ "max_tokens": 65536, "mode": "chat", "output_cost_per_token": 2.08e-06, - "source": "https://openrouter.ai/qwen/qwen3.5-122b-a10b", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_reasoning": true, + "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/qwen/qwen3.5-flash-02-23": { "input_cost_per_token": 6.5e-08, @@ -42138,11 +42594,16 @@ "max_tokens": 65536, "mode": "chat", "output_cost_per_token": 2.6e-07, - "source": "https://openrouter.ai/qwen/qwen3.5-flash-02-23", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_reasoning": true, + "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/qwen/qwen3.5-plus-02-15": { "input_cost_per_token": 2.6e-07, @@ -42154,25 +42615,36 @@ "mode": "chat", "output_cost_per_token": 1.56e-06, "output_cost_per_token_above_256k_tokens": 3e-06, - "source": "https://openrouter.ai/qwen/qwen3.5-plus-02-15", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_reasoning": true, + "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/qwen/qwen3.5-397b-a17b": { + "cache_read_input_token_cost": 2.25e-07, "input_cost_per_token": 5.5e-07, "litellm_provider": "openrouter", "max_input_tokens": 262144, - "max_output_tokens": 65536, - "max_tokens": 65536, + "max_output_tokens": 235929, + "max_tokens": 235929, "mode": "chat", "output_cost_per_token": 3.5e-06, - "source": "https://openrouter.ai/qwen/qwen3.5-397b-a17b", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, "supports_reasoning": true, + "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/switchpoint/router": { "input_cost_per_token": 8.5e-07, @@ -42186,14 +42658,23 @@ "supports_tool_choice": true }, "openrouter/undi95/remm-slerp-l2-13b": { - "input_cost_per_token": 4.5e-07, + "input_cost_per_token": 3.5e-07, "litellm_provider": "openrouter", - "max_tokens": 4096, + "max_tokens": 5529, "mode": "chat", "output_cost_per_token": 6.5e-07, - "supports_tool_choice": true, + "supports_tool_choice": false, "max_input_tokens": 6144, - "max_output_tokens": 4096 + "max_output_tokens": 5529, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_vision": false, + "supports_web_search": false }, "openrouter/x-ai/grok-4": { "input_cost_per_token": 3e-06, @@ -42212,17 +42693,22 @@ "openrouter/z-ai/glm-4.6": { "input_cost_per_token": 4.3e-07, "litellm_provider": "openrouter", - "max_input_tokens": 202800, - "max_output_tokens": 131000, - "max_tokens": 131000, + "max_input_tokens": 204800, + "max_output_tokens": 16384, + "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1.75e-06, - "source": "https://openrouter.ai/z-ai/glm-4.6", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_tool_choice": true, - "cache_read_input_token_cost": 8e-08 + "cache_read_input_token_cost": 8e-08, + "supports_audio_input": false, + "supports_pdf_input": false, + "supports_response_schema": true, + "supports_vision": false, + "supports_web_search": false }, "openrouter/z-ai/glm-4.6:exacto": { "input_cost_per_token": 4.5e-07, @@ -42260,16 +42746,20 @@ "cache_creation_input_token_cost": 0.0, "cache_read_input_token_cost": 3.6e-09, "litellm_provider": "openrouter", - "max_input_tokens": 1048576, - "max_output_tokens": 16384, - "max_tokens": 16384, + "max_input_tokens": 1050000, + "max_output_tokens": 131072, + "max_tokens": 131072, "mode": "chat", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_vision": false, "supports_response_schema": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/xiaomi/mimo-v2.5": { "input_cost_per_token": 1.4e-07, @@ -42277,18 +42767,21 @@ "cache_creation_input_token_cost": 0.0, "cache_read_input_token_cost": 2.8e-09, "litellm_provider": "openrouter", - "max_input_tokens": 1048576, + "max_input_tokens": 1050000, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, "supports_vision": true, "supports_audio_input": true, + "supports_pdf_input": false, "supports_video_input": true, "supports_response_schema": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/z-ai/glm-4.7": { "input_cost_per_token": 4e-07, @@ -42296,45 +42789,62 @@ "cache_creation_input_token_cost": 0.0, "cache_read_input_token_cost": 8e-08, "litellm_provider": "openrouter", - "max_input_tokens": 202752, - "max_output_tokens": 64000, - "max_tokens": 64000, + "max_input_tokens": 204800, + "max_output_tokens": 131072, + "max_tokens": 131072, "mode": "chat", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, - "supports_vision": true, - "supports_prompt_caching": false, - "supports_assistant_prefill": true + "supports_vision": false, + "supports_prompt_caching": true, + "supports_assistant_prefill": true, + "supports_audio_input": false, + "supports_pdf_input": false, + "supports_response_schema": true, + "supports_web_search": false }, "openrouter/z-ai/glm-4.7-flash": { - "input_cost_per_token": 6e-08, + "input_cost_per_token": 6.05e-08, "output_cost_per_token": 4e-07, "cache_creation_input_token_cost": 0.0, "cache_read_input_token_cost": 1e-08, "litellm_provider": "openrouter", "max_input_tokens": 200000, - "max_output_tokens": 32000, - "max_tokens": 32000, + "max_output_tokens": 117964, + "max_tokens": 117964, "mode": "chat", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, - "supports_vision": true, - "supports_prompt_caching": false + "supports_vision": false, + "supports_prompt_caching": false, + "supports_response_schema": true, + "supports_web_search": false }, "openrouter/z-ai/glm-5": { + "cache_read_input_token_cost": 1.2e-07, "input_cost_per_token": 6e-07, "litellm_provider": "openrouter", - "max_input_tokens": 202752, + "max_input_tokens": 204800, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1.92e-06, - "source": "https://openrouter.ai/z-ai/glm-5", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, "supports_reasoning": true, - "supports_tool_choice": true + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false }, "openrouter/z-ai/glm-5.1": { "input_cost_per_token": 9.66e-07, @@ -42342,15 +42852,20 @@ "cache_read_input_token_cost": 1.794e-07, "cache_creation_input_token_cost": 0.0, "litellm_provider": "openrouter", - "max_input_tokens": 202752, - "max_output_tokens": 65535, - "max_tokens": 65535, + "max_input_tokens": 204800, + "max_output_tokens": 128000, + "max_tokens": 128000, "mode": "chat", - "source": "https://openrouter.ai/z-ai/glm-5.1", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_prompt_caching": true, "supports_reasoning": true, - "supports_tool_choice": true + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false }, "openrouter/minimax/minimax-m2.1": { "input_cost_per_token": 3e-07, @@ -42358,33 +42873,42 @@ "cache_creation_input_token_cost": 0.0, "cache_read_input_token_cost": 3e-08, "litellm_provider": "openrouter", - "max_input_tokens": 204000, - "max_output_tokens": 64000, - "max_tokens": 64000, + "max_input_tokens": 204800, + "max_output_tokens": 131072, + "max_tokens": 131072, "mode": "chat", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, - "supports_vision": true, - "supports_prompt_caching": false, - "supports_computer_use": false + "supports_vision": false, + "supports_prompt_caching": true, + "supports_computer_use": false, + "supports_pdf_input": false, + "supports_response_schema": true, + "supports_web_search": false }, "openrouter/minimax/minimax-m2.5": { "input_cost_per_token": 2.7e-07, "output_cost_per_token": 1.08e-06, "cache_read_input_token_cost": 2.7e-08, "litellm_provider": "openrouter", - "max_input_tokens": 196608, - "max_output_tokens": 65536, - "max_tokens": 65536, + "max_input_tokens": 204800, + "max_output_tokens": 128000, + "max_tokens": 128000, "mode": "chat", - "source": "https://openrouter.ai/minimax/minimax-m2.5", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, "supports_vision": false, "supports_prompt_caching": true, - "supports_computer_use": false + "supports_computer_use": false, + "supports_pdf_input": false, + "supports_response_schema": true, + "supports_web_search": false }, "openrouter/openrouter/auto": { "input_cost_per_token": 0, @@ -42423,18 +42947,24 @@ "mode": "chat" }, "openrouter/stealth/union-alpha": { - "input_cost_per_token": 0, - "output_cost_per_token": 0, + "deprecation_date": "2098-12-31", + "input_cost_per_token": 0.0, + "output_cost_per_token": 0.0, "litellm_provider": "openrouter", "max_input_tokens": 262144, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", - "source": "https://openrouter.ai/stealth/union-alpha", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, "supports_tool_choice": true, "supports_response_schema": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "ovhcloud/DeepSeek-R1-Distill-Llama-70B": { "input_cost_per_token": 6.7e-07, @@ -64526,7 +65056,7 @@ "supports_sampling_params": false, "supports_adaptive_thinking": true, "thinking_always_on": true, - "source": "https://openrouter.ai/anthropic/claude-fable-5", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, @@ -64537,7 +65067,9 @@ "cache_read_input_token_cost": 1e-06, "supports_prompt_caching": true, "cache_creation_input_token_cost": 1.25e-05, - "prompt_cache_min_tokens": 512 + "cache_creation_input_token_cost_above_1hr": 2e-05, + "prompt_cache_min_tokens": 512, + "supports_web_search": false }, "openrouter/anthropic/claude-fable-5.1": { "input_cost_per_token": 1e-05, @@ -64550,7 +65082,7 @@ "supports_sampling_params": false, "supports_adaptive_thinking": true, "thinking_always_on": true, - "source": "https://openrouter.ai/anthropic/claude-fable-5.1", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": false, "supports_reasoning": true, @@ -64561,7 +65093,9 @@ "cache_read_input_token_cost": 2.5e-07, "supports_prompt_caching": true, "cache_creation_input_token_cost": 1.25e-05, - "prompt_cache_min_tokens": 512 + "cache_creation_input_token_cost_above_1hr": 2e-05, + "prompt_cache_min_tokens": 512, + "supports_web_search": false }, "openrouter/anthropic/claude-opus-4.8": { "input_cost_per_token": 5e-06, @@ -64573,7 +65107,7 @@ "mode": "chat", "supports_sampling_params": false, "supports_adaptive_thinking": true, - "source": "https://openrouter.ai/anthropic/claude-opus-4.8", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, @@ -64583,7 +65117,9 @@ "supports_audio_input": false, "cache_read_input_token_cost": 5e-07, "supports_prompt_caching": true, - "cache_creation_input_token_cost": 6.25e-06 + "cache_creation_input_token_cost": 6.25e-06, + "cache_creation_input_token_cost_above_1hr": 1e-05, + "supports_web_search": false }, "openrouter/anthropic/claude-sonnet-5": { "input_cost_per_token": 2e-06, @@ -64595,7 +65131,7 @@ "mode": "chat", "supports_sampling_params": false, "supports_adaptive_thinking": true, - "source": "https://openrouter.ai/anthropic/claude-sonnet-5", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, @@ -64605,9 +65141,13 @@ "supports_audio_input": false, "cache_read_input_token_cost": 2e-07, "supports_prompt_caching": true, - "cache_creation_input_token_cost": 2.5e-06 + "cache_creation_input_token_cost": 2.5e-06, + "cache_creation_input_token_cost_above_1hr": 4e-06, + "supports_web_search": false }, "openrouter/google/gemini-2.5-flash-lite": { + "cache_creation_input_token_cost": 8.33333333333333e-08, + "cache_read_input_audio_token_cost": 3e-08, "input_cost_per_token": 1e-07, "output_cost_per_token": 4e-07, "litellm_provider": "openrouter", @@ -64615,7 +65155,7 @@ "max_output_tokens": 65535, "max_tokens": 65535, "mode": "chat", - "source": "https://openrouter.ai/google/gemini-2.5-flash-lite", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, @@ -64624,17 +65164,21 @@ "supports_pdf_input": true, "supports_audio_input": true, "cache_read_input_token_cost": 1e-08, - "supports_prompt_caching": true + "input_cost_per_audio_token": 3e-07, + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/google/gemini-3.5-flash": { + "cache_creation_input_token_cost": 8.33333333333333e-08, + "cache_read_input_audio_token_cost": 3e-07, "input_cost_per_token": 1.5e-06, "output_cost_per_token": 9e-06, "litellm_provider": "openrouter", "max_input_tokens": 1048576, - "max_output_tokens": 65535, - "max_tokens": 65535, + "max_output_tokens": 65536, + "max_tokens": 65536, "mode": "chat", - "source": "https://openrouter.ai/google/gemini-3.5-flash", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, @@ -64643,9 +65187,13 @@ "supports_pdf_input": true, "supports_audio_input": true, "cache_read_input_token_cost": 1.5e-07, - "supports_prompt_caching": true + "input_cost_per_audio_token": 3e-06, + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/google/gemini-3.5-flash-lite": { + "cache_creation_input_token_cost": 8.33333333333333e-08, + "cache_read_input_audio_token_cost": 3e-08, "input_cost_per_token": 3e-07, "output_cost_per_token": 2.5e-06, "litellm_provider": "openrouter", @@ -64653,7 +65201,7 @@ "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", - "source": "https://openrouter.ai/google/gemini-3.5-flash-lite", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, @@ -64662,9 +65210,13 @@ "supports_pdf_input": true, "supports_audio_input": true, "cache_read_input_token_cost": 3e-08, - "supports_prompt_caching": true + "input_cost_per_audio_token": 3e-07, + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/google/gemini-3.6-flash": { + "cache_creation_input_token_cost": 4.16666666666667e-08, + "cache_read_input_audio_token_cost": 7.5e-08, "input_cost_per_token": 7.5e-07, "output_cost_per_token": 3.75e-06, "litellm_provider": "openrouter", @@ -64672,7 +65224,7 @@ "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", - "source": "https://openrouter.ai/google/gemini-3.6-flash", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, @@ -64681,9 +65233,13 @@ "supports_pdf_input": true, "supports_audio_input": true, "cache_read_input_token_cost": 7.5e-08, - "supports_prompt_caching": true + "input_cost_per_audio_token": 7.5e-07, + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/google/gemini-3.7-flash": { + "cache_creation_input_token_cost": 4.16666666666667e-08, + "cache_read_input_audio_token_cost": 7.5e-08, "input_cost_per_token": 7.5e-07, "output_cost_per_token": 3.75e-06, "litellm_provider": "openrouter", @@ -64691,7 +65247,7 @@ "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", - "source": "https://openrouter.ai/google/gemini-3.7-flash", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, @@ -64700,9 +65256,13 @@ "supports_pdf_input": true, "supports_audio_input": true, "cache_read_input_token_cost": 7.5e-08, - "supports_prompt_caching": true + "input_cost_per_audio_token": 7.5e-07, + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/google/gemini-3.8-flash": { + "cache_creation_input_token_cost": 4.16666666666667e-08, + "cache_read_input_audio_token_cost": 7.5e-08, "input_cost_per_token": 7.5e-07, "output_cost_per_token": 3.75e-06, "litellm_provider": "openrouter", @@ -64710,7 +65270,7 @@ "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", - "source": "https://openrouter.ai/google/gemini-3.8-flash", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, @@ -64719,7 +65279,9 @@ "supports_pdf_input": true, "supports_audio_input": true, "cache_read_input_token_cost": 7.5e-08, - "supports_prompt_caching": true + "input_cost_per_audio_token": 7.5e-07, + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/openai/gpt-4o-mini": { "input_cost_per_token": 1.5e-07, @@ -64729,7 +65291,7 @@ "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", - "source": "https://openrouter.ai/openai/gpt-4o-mini", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": false, @@ -64738,17 +65300,18 @@ "supports_pdf_input": true, "supports_audio_input": false, "cache_read_input_token_cost": 7.5e-08, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": true }, "openrouter/openai/gpt-5.1": { "input_cost_per_token": 1.25e-06, "output_cost_per_token": 1e-05, "litellm_provider": "openrouter", - "max_input_tokens": 272000, + "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "source": "https://openrouter.ai/openai/gpt-5.1", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, @@ -64757,17 +65320,18 @@ "supports_pdf_input": true, "supports_audio_input": false, "cache_read_input_token_cost": 1.25e-07, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/openai/gpt-5.3-codex": { "input_cost_per_token": 1.75e-06, "output_cost_per_token": 1.4e-05, "litellm_provider": "openrouter", - "max_input_tokens": 272000, + "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "source": "https://openrouter.ai/openai/gpt-5.3-codex", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, @@ -64776,7 +65340,8 @@ "supports_pdf_input": true, "supports_audio_input": false, "cache_read_input_token_cost": 1.75e-07, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/openai/gpt-5.4": { "input_cost_per_token": 2.5e-06, @@ -64786,7 +65351,7 @@ "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "source": "https://openrouter.ai/openai/gpt-5.4", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, @@ -64795,17 +65360,21 @@ "supports_pdf_input": true, "supports_audio_input": false, "cache_read_input_token_cost": 2.5e-07, - "supports_prompt_caching": true + "cache_read_input_token_cost_above_272k_tokens": 5e-07, + "input_cost_per_token_above_272k_tokens": 5e-06, + "output_cost_per_token_above_272k_tokens": 2.25e-05, + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/openai/gpt-5.4-mini": { "input_cost_per_token": 7.5e-07, "output_cost_per_token": 4.5e-06, "litellm_provider": "openrouter", - "max_input_tokens": 272000, + "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "source": "https://openrouter.ai/openai/gpt-5.4-mini", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, @@ -64814,17 +65383,18 @@ "supports_pdf_input": true, "supports_audio_input": false, "cache_read_input_token_cost": 7.5e-08, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/openai/gpt-5.4-nano": { "input_cost_per_token": 2e-07, "output_cost_per_token": 1.25e-06, "litellm_provider": "openrouter", - "max_input_tokens": 272000, + "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "source": "https://openrouter.ai/openai/gpt-5.4-nano", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, @@ -64833,7 +65403,8 @@ "supports_pdf_input": true, "supports_audio_input": false, "cache_read_input_token_cost": 2e-08, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/openai/gpt-5.5": { "input_cost_per_token": 5e-06, @@ -64843,7 +65414,7 @@ "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "source": "https://openrouter.ai/openai/gpt-5.5", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, @@ -64852,17 +65423,23 @@ "supports_pdf_input": true, "supports_audio_input": false, "cache_read_input_token_cost": 5e-07, - "supports_prompt_caching": true + "cache_read_input_token_cost_above_272k_tokens": 1e-06, + "input_cost_per_token_above_272k_tokens": 1e-05, + "output_cost_per_token_above_272k_tokens": 4.5e-05, + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/openai/gpt-5.6-luna": { + "cache_creation_input_token_cost": 2.5e-07, + "cache_creation_input_token_cost_above_272k_tokens": 5e-07, "input_cost_per_token": 2e-07, "output_cost_per_token": 1.2e-06, "litellm_provider": "openrouter", - "max_input_tokens": 922000, + "max_input_tokens": 1050000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "source": "https://openrouter.ai/openai/gpt-5.6-luna", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, @@ -64871,13 +65448,18 @@ "supports_pdf_input": true, "supports_audio_input": false, "cache_read_input_token_cost": 2e-08, - "supports_prompt_caching": true + "cache_read_input_token_cost_above_272k_tokens": 4e-08, + "input_cost_per_token_above_272k_tokens": 4e-07, + "output_cost_per_token_above_272k_tokens": 1.8e-06, + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/openai/gpt-5.6-luna-pro": { "input_cost_per_token": 2e-07, "output_cost_per_token": 1.2e-06, "cache_read_input_token_cost": 2e-08, "cache_creation_input_token_cost": 2.5e-07, + "cache_creation_input_token_cost_above_272k_tokens": 5e-07, "input_cost_per_token_above_272k_tokens": 4e-07, "output_cost_per_token_above_272k_tokens": 1.8e-06, "cache_read_input_token_cost_above_272k_tokens": 4e-08, @@ -64886,24 +65468,28 @@ "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "source": "https://openrouter.ai/openai/gpt-5.6-luna-pro", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, "supports_pdf_input": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/openai/gpt-5.6-terra": { + "cache_creation_input_token_cost": 2.5e-06, + "cache_creation_input_token_cost_above_272k_tokens": 5e-06, "input_cost_per_token": 2e-06, "output_cost_per_token": 1.2e-05, "litellm_provider": "openrouter", - "max_input_tokens": 922000, + "max_input_tokens": 1050000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "source": "https://openrouter.ai/openai/gpt-5.6-terra", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, @@ -64912,13 +65498,18 @@ "supports_pdf_input": true, "supports_audio_input": false, "cache_read_input_token_cost": 2e-07, - "supports_prompt_caching": true + "cache_read_input_token_cost_above_272k_tokens": 4e-07, + "input_cost_per_token_above_272k_tokens": 4e-06, + "output_cost_per_token_above_272k_tokens": 1.8e-05, + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/openai/gpt-5.6-terra-pro": { "input_cost_per_token": 2e-06, "output_cost_per_token": 1.2e-05, "cache_read_input_token_cost": 2e-07, "cache_creation_input_token_cost": 2.5e-06, + "cache_creation_input_token_cost_above_272k_tokens": 5e-06, "input_cost_per_token_above_272k_tokens": 4e-06, "output_cost_per_token_above_272k_tokens": 1.8e-05, "cache_read_input_token_cost_above_272k_tokens": 4e-07, @@ -64927,14 +65518,16 @@ "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "source": "https://openrouter.ai/openai/gpt-5.6-terra-pro", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, "supports_pdf_input": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/openai/o3": { "input_cost_per_token": 2e-06, @@ -64944,7 +65537,7 @@ "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", - "source": "https://openrouter.ai/openai/o3", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, @@ -64953,7 +65546,8 @@ "supports_pdf_input": true, "supports_audio_input": false, "cache_read_input_token_cost": 5e-07, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/openai/o4-mini": { "input_cost_per_token": 1.1e-06, @@ -64963,7 +65557,7 @@ "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", - "source": "https://openrouter.ai/openai/o4-mini", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, @@ -64972,17 +65566,18 @@ "supports_pdf_input": true, "supports_audio_input": false, "cache_read_input_token_cost": 2.75e-07, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/x-ai/grok-4.20": { "input_cost_per_token": 1.25e-06, "output_cost_per_token": 2.5e-06, "litellm_provider": "openrouter", - "max_input_tokens": 1000000, - "max_output_tokens": 1000000, - "max_tokens": 1000000, + "max_input_tokens": 2000000, + "max_output_tokens": 1800000, + "max_tokens": 1800000, "mode": "chat", - "source": "https://openrouter.ai/x-ai/grok-4.20", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, @@ -64991,17 +65586,21 @@ "supports_pdf_input": true, "supports_audio_input": false, "cache_read_input_token_cost": 2e-07, - "supports_prompt_caching": true + "cache_read_input_token_cost_above_200k_tokens": 4e-07, + "input_cost_per_token_above_200k_tokens": 2.5e-06, + "output_cost_per_token_above_200k_tokens": 5e-06, + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/x-ai/grok-4.20-multi-agent": { "input_cost_per_token": 1.25e-06, "output_cost_per_token": 2.5e-06, "litellm_provider": "openrouter", - "max_input_tokens": 1000000, - "max_output_tokens": 1000000, - "max_tokens": 1000000, + "max_input_tokens": 2000000, + "max_output_tokens": 1800000, + "max_tokens": 1800000, "mode": "chat", - "source": "https://openrouter.ai/x-ai/grok-4.20-multi-agent", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": false, "supports_tool_choice": false, "supports_reasoning": true, @@ -65010,17 +65609,21 @@ "supports_pdf_input": true, "supports_audio_input": false, "cache_read_input_token_cost": 2e-07, - "supports_prompt_caching": true + "cache_read_input_token_cost_above_200k_tokens": 4e-07, + "input_cost_per_token_above_200k_tokens": 2.5e-06, + "output_cost_per_token_above_200k_tokens": 5e-06, + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/x-ai/grok-4.3": { "input_cost_per_token": 1.25e-06, "output_cost_per_token": 2.5e-06, "litellm_provider": "openrouter", "max_input_tokens": 1000000, - "max_output_tokens": 1000000, - "max_tokens": 1000000, + "max_output_tokens": 900000, + "max_tokens": 900000, "mode": "chat", - "source": "https://openrouter.ai/x-ai/grok-4.3", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, @@ -65029,17 +65632,21 @@ "supports_pdf_input": true, "supports_audio_input": false, "cache_read_input_token_cost": 2e-07, - "supports_prompt_caching": true + "cache_read_input_token_cost_above_200k_tokens": 4e-07, + "input_cost_per_token_above_200k_tokens": 2.5e-06, + "output_cost_per_token_above_200k_tokens": 5e-06, + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/x-ai/grok-4.5": { "input_cost_per_token": 2e-06, "output_cost_per_token": 6e-06, "litellm_provider": "openrouter", "max_input_tokens": 500000, - "max_output_tokens": 500000, - "max_tokens": 500000, + "max_output_tokens": 450000, + "max_tokens": 450000, "mode": "chat", - "source": "https://openrouter.ai/x-ai/grok-4.5", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, @@ -65048,17 +65655,21 @@ "supports_pdf_input": true, "supports_audio_input": false, "cache_read_input_token_cost": 3e-07, - "supports_prompt_caching": true + "cache_read_input_token_cost_above_200k_tokens": 6e-07, + "input_cost_per_token_above_200k_tokens": 4e-06, + "output_cost_per_token_above_200k_tokens": 1.2e-05, + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/x-ai/grok-4.6": { "input_cost_per_token": 2e-06, "output_cost_per_token": 6e-06, "litellm_provider": "openrouter", "max_input_tokens": 500000, - "max_output_tokens": 500000, - "max_tokens": 500000, + "max_output_tokens": 450000, + "max_tokens": 450000, "mode": "chat", - "source": "https://openrouter.ai/x-ai/grok-4.6", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, @@ -65067,17 +65678,21 @@ "supports_pdf_input": true, "supports_audio_input": false, "cache_read_input_token_cost": 5e-07, - "supports_prompt_caching": true + "cache_read_input_token_cost_above_200k_tokens": 1e-06, + "input_cost_per_token_above_200k_tokens": 4e-06, + "output_cost_per_token_above_200k_tokens": 1.2e-05, + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/x-ai/grok-build-0.1": { "input_cost_per_token": 1e-06, "output_cost_per_token": 2e-06, "litellm_provider": "openrouter", "max_input_tokens": 256000, - "max_output_tokens": 256000, - "max_tokens": 256000, + "max_output_tokens": 230400, + "max_tokens": 230400, "mode": "chat", - "source": "https://openrouter.ai/x-ai/grok-build-0.1", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, @@ -65086,7 +65701,11 @@ "supports_pdf_input": true, "supports_audio_input": false, "cache_read_input_token_cost": 2e-07, - "supports_prompt_caching": true + "cache_read_input_token_cost_above_200k_tokens": 4e-07, + "input_cost_per_token_above_200k_tokens": 2e-06, + "output_cost_per_token_above_200k_tokens": 4e-06, + "supports_prompt_caching": true, + "supports_web_search": false }, "baseten/zai-org/GLM-5.3": { "cache_read_input_token_cost": 1.4e-07, @@ -65119,14 +65738,17 @@ "max_output_tokens": 512000, "max_tokens": 512000, "mode": "chat", - "source": "https://openrouter.ai/minimax/minimax-m3", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, "cache_read_input_token_cost": 6e-08, - "supports_prompt_caching": true + "supports_audio_input": false, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/qwen/qwen3.7-plus": { "input_cost_per_token": 3.2e-07, @@ -65136,7 +65758,7 @@ "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3.7-plus", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, @@ -65144,7 +65766,10 @@ "supports_vision": true, "cache_read_input_token_cost": 6.4e-08, "supports_prompt_caching": true, - "cache_creation_input_token_cost": 4e-07 + "cache_creation_input_token_cost": 4e-07, + "supports_audio_input": false, + "supports_pdf_input": false, + "supports_web_search": false }, "openrouter/openai/gpt-6-astra": { "input_cost_per_token": 1e-05, @@ -65160,20 +65785,23 @@ "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "source": "https://openrouter.ai/openai/gpt-6-astra", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, "supports_pdf_input": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/openai/gpt-6-astra-pro": { "input_cost_per_token": 1e-05, "output_cost_per_token": 5e-05, "cache_read_input_token_cost": 1e-06, "cache_creation_input_token_cost": 1.25e-05, + "cache_creation_input_token_cost_above_272k_tokens": 2.5e-05, "input_cost_per_token_above_272k_tokens": 2e-05, "output_cost_per_token_above_272k_tokens": 7.5e-05, "cache_read_input_token_cost_above_272k_tokens": 2e-06, @@ -65182,14 +65810,16 @@ "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "source": "https://openrouter.ai/openai/gpt-6-astra-pro", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, "supports_pdf_input": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/qwen/qwen3.8-flash": { "input_cost_per_token": 1.5e-07, @@ -65201,13 +65831,16 @@ "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3.8-flash", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/z-ai/glm-5.3-flash": { "input_cost_per_token": 9e-08, @@ -65218,48 +65851,57 @@ "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", - "source": "https://openrouter.ai/z-ai/glm-5.3-flash", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/deepseek/deepseek-v4-flash-vision-exp": { - "input_cost_per_token": 2.2e-07, - "output_cost_per_token": 6.6e-07, - "cache_read_input_token_cost": 7e-09, + "input_cost_per_token": 2.156e-07, + "output_cost_per_token": 6.468e-07, + "cache_read_input_token_cost": 6.86e-09, "litellm_provider": "openrouter", "max_input_tokens": 1048576, - "max_output_tokens": 384000, - "max_tokens": 384000, + "max_output_tokens": 943718, + "max_tokens": 943718, "mode": "chat", - "source": "https://openrouter.ai/deepseek/deepseek-v4-flash-vision-exp", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/z-ai/glm-5.3": { "input_cost_per_token": 1.4e-06, "output_cost_per_token": 4.4e-06, - "cache_read_input_token_cost": 1.4e-07, + "cache_read_input_token_cost": 2.6e-07, "litellm_provider": "openrouter", "max_input_tokens": 1310720, - "max_output_tokens": 262144, - "max_tokens": 262144, + "max_output_tokens": 943717, + "max_tokens": 943717, "mode": "chat", - "source": "https://openrouter.ai/z-ai/glm-5.3", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_parallel_function_calling": true, + "supports_pdf_input": false, "supports_vision": false, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/qwen/qwen3.8-27b": { "input_cost_per_token": 2.14e-07, @@ -65270,13 +65912,16 @@ "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3.8-27b", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/qwen/qwen3.8-2.4t-a95b": { "input_cost_per_token": 2e-06, @@ -65284,16 +65929,19 @@ "cache_read_input_token_cost": 2.5e-07, "litellm_provider": "openrouter", "max_input_tokens": 1048576, - "max_output_tokens": 262144, - "max_tokens": 262144, + "max_output_tokens": 131072, + "max_tokens": 131072, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3.8-2.4t-a95b", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": false, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/nvidia/nemotron-3.5-lightning:free": { "input_cost_per_token": 0.0, @@ -65303,11 +65951,16 @@ "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", - "source": "https://openrouter.ai/nvidia/nemotron-3.5-lightning:free", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": true, "supports_reasoning": true, - "supports_vision": false + "supports_response_schema": false, + "supports_vision": false, + "supports_web_search": false }, "openrouter/qwen/qwen3.8-max": { "input_cost_per_token": 2e-06, @@ -65337,14 +65990,16 @@ "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3.8-max-0902", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, "supports_pdf_input": false, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/deepseek/deepseek-v4-flash-0731": { "input_cost_per_token": 6e-08, @@ -65355,14 +66010,17 @@ "max_output_tokens": 943718, "max_tokens": 943718, "mode": "chat", - "source": "https://openrouter.ai/deepseek/deepseek-v4-flash-0731", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_parallel_function_calling": true, + "supports_pdf_input": false, "supports_vision": false, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/qwen/qwen3.7-flash": { "input_cost_per_token": 3e-08, @@ -65378,13 +66036,16 @@ "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3.7-flash", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/poolside/laguna-s-2.1": { "input_cost_per_token": 9e-08, @@ -65395,12 +66056,16 @@ "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", - "source": "https://openrouter.ai/poolside/laguna-s-2.1", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_vision": false, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_response_schema": false, + "supports_web_search": false }, "openrouter/poolside/laguna-s-2.1:free": { "input_cost_per_token": 0.0, @@ -65410,28 +66075,36 @@ "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", - "source": "https://openrouter.ai/poolside/laguna-s-2.1:free", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": true, "supports_reasoning": true, - "supports_vision": false + "supports_response_schema": false, + "supports_vision": false, + "supports_web_search": false }, "openrouter/moonshotai/kimi-k3": { - "input_cost_per_token": 3e-06, - "output_cost_per_token": 1.5e-05, - "cache_read_input_token_cost": 3e-07, + "input_cost_per_token": 2.1e-06, + "output_cost_per_token": 1.095e-05, + "cache_read_input_token_cost": 2.3e-07, "litellm_provider": "openrouter", "max_input_tokens": 1048576, "max_output_tokens": 943718, "max_tokens": 943718, "mode": "chat", - "source": "https://openrouter.ai/moonshotai/kimi-k3", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/poolside/laguna-xs-2.1": { "input_cost_per_token": 6e-08, @@ -65442,12 +66115,16 @@ "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", - "source": "https://openrouter.ai/poolside/laguna-xs-2.1", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_vision": false, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_response_schema": false, + "supports_web_search": false }, "openrouter/poolside/laguna-xs-2.1:free": { "input_cost_per_token": 0.0, @@ -65457,11 +66134,16 @@ "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", - "source": "https://openrouter.ai/poolside/laguna-xs-2.1:free", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": true, "supports_reasoning": true, - "supports_vision": false + "supports_response_schema": false, + "supports_vision": false, + "supports_web_search": false }, "openrouter/google/gemini-3.1-flash-lite-image": { "input_cost_per_token": 2.5e-07, @@ -65472,12 +66154,16 @@ "max_output_tokens": 58982, "max_tokens": 58982, "mode": "chat", - "source": "https://openrouter.ai/google/gemini-3.1-flash-lite-image", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": false, "supports_reasoning": true, "supports_response_schema": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/google/gemini-3.1-flash-image": { "input_cost_per_token": 5e-07, @@ -65488,18 +66174,23 @@ "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", - "source": "https://openrouter.ai/google/gemini-3.1-flash-image", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": false, "supports_reasoning": true, "supports_response_schema": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/google/gemini-3-pro-image": { "input_cost_per_token": 2e-06, "output_cost_per_token": 1.2e-05, "cache_read_input_token_cost": 2e-07, "cache_creation_input_token_cost": 3.75e-07, + "cache_read_input_audio_token_cost": 2e-07, "input_cost_per_audio_token": 2e-06, "output_cost_per_image_token": 0.00012, "litellm_provider": "openrouter", @@ -65507,46 +66198,56 @@ "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", - "source": "https://openrouter.ai/google/gemini-3-pro-image", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/z-ai/glm-5.2": { - "input_cost_per_token": 1.4e-06, - "output_cost_per_token": 4.4e-06, - "cache_read_input_token_cost": 1.4e-07, + "input_cost_per_token": 4.875e-07, + "output_cost_per_token": 1.56e-06, + "cache_read_input_token_cost": 9.1e-08, "litellm_provider": "openrouter", "max_input_tokens": 1048576, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", - "source": "https://openrouter.ai/z-ai/glm-5.2", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_parallel_function_calling": true, + "supports_pdf_input": false, "supports_vision": false, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/z-ai/glm-5.2:free": { "input_cost_per_token": 0.0, "output_cost_per_token": 0.0, "litellm_provider": "openrouter", - "max_input_tokens": 256000, - "max_output_tokens": 230400, - "max_tokens": 230400, + "max_input_tokens": 32768, + "max_output_tokens": 29491, + "max_tokens": 29491, "mode": "chat", - "source": "https://openrouter.ai/z-ai/glm-5.2:free", - "supports_function_calling": true, - "supports_tool_choice": true, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_tool_choice": false, "supports_reasoning": true, - "supports_response_schema": true, - "supports_vision": false + "supports_response_schema": false, + "supports_vision": false, + "supports_web_search": false }, "openrouter/moonshotai/kimi-k2.7-code": { "input_cost_per_token": 7.062e-07, @@ -65557,14 +66258,17 @@ "max_output_tokens": 235929, "max_tokens": 235929, "mode": "chat", - "source": "https://openrouter.ai/moonshotai/kimi-k2.7-code", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_parallel_function_calling": true, + "supports_pdf_input": false, "supports_vision": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/nvidia/nemotron-3.5-content-safety": { "input_cost_per_token": 2e-07, @@ -65574,12 +66278,16 @@ "max_output_tokens": 117964, "max_tokens": 117964, "mode": "chat", - "source": "https://openrouter.ai/nvidia/nemotron-3.5-content-safety", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": false, "supports_reasoning": true, - "supports_response_schema": true, - "supports_vision": true + "supports_response_schema": false, + "supports_vision": true, + "supports_web_search": false }, "openrouter/nvidia/nemotron-3.5-content-safety:free": { "input_cost_per_token": 0.0, @@ -65589,11 +66297,16 @@ "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", - "source": "https://openrouter.ai/nvidia/nemotron-3.5-content-safety:free", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": false, "supports_reasoning": true, - "supports_vision": true + "supports_response_schema": false, + "supports_vision": true, + "supports_web_search": false }, "openrouter/nvidia/nemotron-3-ultra-550b-a55b": { "input_cost_per_token": 6.25e-07, @@ -65604,13 +66317,16 @@ "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", - "source": "https://openrouter.ai/nvidia/nemotron-3-ultra-550b-a55b", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": false, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/nvidia/nemotron-3-ultra-550b-a55b:free": { "input_cost_per_token": 0.0, @@ -65620,11 +66336,16 @@ "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", - "source": "https://openrouter.ai/nvidia/nemotron-3-ultra-550b-a55b:free", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": true, "supports_reasoning": true, - "supports_vision": false + "supports_response_schema": false, + "supports_vision": false, + "supports_web_search": false }, "openrouter/minimax/minimax-m3:free": { "input_cost_per_token": 0.0, @@ -65651,13 +66372,16 @@ "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3.7-max", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": false, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/mistralai/mistral-medium-3-5": { "input_cost_per_token": 1.5e-06, @@ -65667,13 +66391,16 @@ "max_output_tokens": 209715, "max_tokens": 209715, "mode": "chat", - "source": "https://openrouter.ai/mistralai/mistral-medium-3-5", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, - "supports_pdf_input": true + "supports_pdf_input": true, + "supports_prompt_caching": false, + "supports_web_search": false }, "openrouter/nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free": { "input_cost_per_token": 0.0, @@ -65683,12 +66410,16 @@ "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", - "source": "https://openrouter.ai/nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, "supports_vision": true, - "supports_audio_input": true + "supports_audio_input": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_response_schema": false, + "supports_web_search": false }, "openrouter/qwen/qwen3.5-plus-20260420": { "input_cost_per_token": 3e-07, @@ -65702,12 +66433,16 @@ "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3.5-plus-20260420", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/qwen/qwen3.6-flash": { "input_cost_per_token": 1.875e-07, @@ -65721,12 +66456,16 @@ "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3.6-flash", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/qwen/qwen3.6-35b-a3b": { "input_cost_per_token": 1e-07, @@ -65737,13 +66476,16 @@ "max_output_tokens": 235929, "max_tokens": 235929, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3.6-35b-a3b", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/qwen/qwen3.6-max-preview": { "input_cost_per_token": 1.027e-06, @@ -65757,12 +66499,16 @@ "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3.6-max-preview", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, - "supports_vision": false + "supports_vision": false, + "supports_web_search": false }, "openrouter/qwen/qwen3.6-27b": { "input_cost_per_token": 3e-07, @@ -65773,13 +66519,16 @@ "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3.6-27b", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/openai/gpt-5.5-pro": { "input_cost_per_token": 3e-05, @@ -65791,13 +66540,16 @@ "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "source": "https://openrouter.ai/openai/gpt-5.5-pro", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, - "supports_pdf_input": true + "supports_pdf_input": true, + "supports_prompt_caching": false, + "supports_web_search": false }, "openrouter/openai/gpt-chat-latest": { "input_cost_per_token": 5e-06, @@ -65808,31 +66560,36 @@ "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "source": "https://openrouter.ai/openai/gpt-chat-latest", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": false, "supports_response_schema": true, "supports_vision": true, "supports_pdf_input": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/deepseek/deepseek-v4-flash": { - "input_cost_per_token": 8.54e-08, - "output_cost_per_token": 1.708e-07, - "cache_read_input_token_cost": 1.708e-08, + "input_cost_per_token": 8.8606e-08, + "output_cost_per_token": 1.77212e-07, + "cache_read_input_token_cost": 1.77212e-08, "litellm_provider": "openrouter", "max_input_tokens": 1048576, "max_output_tokens": 384000, "max_tokens": 384000, "mode": "chat", - "source": "https://openrouter.ai/deepseek/deepseek-v4-flash", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": false, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/moonshotai/kimi-k2.6": { "input_cost_per_token": 9.5e-07, @@ -65843,29 +66600,37 @@ "max_output_tokens": 235929, "max_tokens": 235929, "mode": "chat", - "source": "https://openrouter.ai/moonshotai/kimi-k2.6", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_parallel_function_calling": true, + "supports_pdf_input": false, "supports_vision": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/google/gemma-4-26b-a4b-it": { + "cache_read_input_token_cost": 5e-08, "input_cost_per_token": 9e-08, "output_cost_per_token": 3e-07, "litellm_provider": "openrouter", "max_input_tokens": 262144, - "max_output_tokens": 16384, - "max_tokens": 16384, + "max_output_tokens": 235929, + "max_tokens": 235929, "mode": "chat", - "source": "https://openrouter.ai/google/gemma-4-26b-a4b-it", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/google/gemma-4-26b-a4b-it:free": { "input_cost_per_token": 0.0, @@ -65875,12 +66640,16 @@ "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", - "source": "https://openrouter.ai/google/gemma-4-26b-a4b-it:free", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/google/gemma-4-31b-it": { "input_cost_per_token": 9e-08, @@ -65891,13 +66660,16 @@ "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", - "source": "https://openrouter.ai/google/gemma-4-31b-it", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/google/gemma-4-31b-it:free": { "input_cost_per_token": 0.0, @@ -65907,29 +66679,37 @@ "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", - "source": "https://openrouter.ai/google/gemma-4-31b-it:free", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/z-ai/glm-5v-turbo": { "input_cost_per_token": 1.2e-06, "output_cost_per_token": 4e-06, "cache_read_input_token_cost": 2.4e-07, + "deprecation_date": "2098-12-31", "litellm_provider": "openrouter", "max_input_tokens": 202752, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", - "source": "https://openrouter.ai/z-ai/glm-5v-turbo", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/minimax/minimax-m2.7": { "input_cost_per_token": 3e-07, @@ -65940,13 +66720,16 @@ "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", - "source": "https://openrouter.ai/minimax/minimax-m2.7", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": false, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/minimax/minimax-m2.7:free": { "input_cost_per_token": 0.0, @@ -65972,45 +66755,56 @@ "max_output_tokens": 209715, "max_tokens": 209715, "mode": "chat", - "source": "https://openrouter.ai/mistralai/mistral-small-2603", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/z-ai/glm-5-turbo": { "input_cost_per_token": 1.2e-06, "output_cost_per_token": 4e-06, "cache_read_input_token_cost": 2.4e-07, + "deprecation_date": "2098-12-31", "litellm_provider": "openrouter", "max_input_tokens": 202752, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", - "source": "https://openrouter.ai/z-ai/glm-5-turbo", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": false, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/nvidia/nemotron-3-super-120b-a12b": { - "input_cost_per_token": 8.5e-08, - "output_cost_per_token": 4e-07, + "input_cost_per_token": 8e-08, + "output_cost_per_token": 4.5e-07, "litellm_provider": "openrouter", - "max_input_tokens": 1000000, - "max_output_tokens": 16384, - "max_tokens": 16384, + "max_input_tokens": 262144, + "max_output_tokens": 235929, + "max_tokens": 235929, "mode": "chat", - "source": "https://openrouter.ai/nvidia/nemotron-3-super-120b-a12b", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, - "supports_vision": false + "supports_vision": false, + "supports_web_search": false }, "openrouter/nvidia/nemotron-3-super-120b-a12b:free": { "input_cost_per_token": 0.0, @@ -66020,12 +66814,16 @@ "max_output_tokens": 235929, "max_tokens": 235929, "mode": "chat", - "source": "https://openrouter.ai/nvidia/nemotron-3-super-120b-a12b:free", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, - "supports_vision": false + "supports_vision": false, + "supports_web_search": false }, "openrouter/qwen/qwen3.5-9b": { "input_cost_per_token": 1e-07, @@ -66035,12 +66833,16 @@ "max_output_tokens": 235929, "max_tokens": 235929, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3.5-9b", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/openai/gpt-5.4-pro": { "input_cost_per_token": 3e-05, @@ -66052,13 +66854,16 @@ "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "source": "https://openrouter.ai/openai/gpt-5.4-pro", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, - "supports_pdf_input": true + "supports_pdf_input": true, + "supports_prompt_caching": false, + "supports_web_search": false }, "openrouter/google/gemini-3.1-flash-image-preview": { "input_cost_per_token": 5e-07, @@ -66069,18 +66874,23 @@ "max_output_tokens": 58982, "max_tokens": 58982, "mode": "chat", - "source": "https://openrouter.ai/google/gemini-3.1-flash-image-preview", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": false, "supports_reasoning": true, "supports_response_schema": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/google/gemini-3.1-pro-preview-customtools": { "input_cost_per_token": 2e-06, "output_cost_per_token": 1.2e-05, "cache_read_input_token_cost": 2e-07, "cache_creation_input_token_cost": 3.75e-07, + "cache_read_input_audio_token_cost": 2e-07, "input_cost_per_audio_token": 2e-06, "input_cost_per_token_above_200k_tokens": 4e-06, "output_cost_per_token_above_200k_tokens": 1.8e-05, @@ -66090,7 +66900,7 @@ "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", - "source": "https://openrouter.ai/google/gemini-3.1-pro-preview-customtools", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, @@ -66098,7 +66908,8 @@ "supports_vision": true, "supports_pdf_input": true, "supports_audio_input": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/qwen/qwen3-max-thinking": { "input_cost_per_token": 7.8e-07, @@ -66110,12 +66921,16 @@ "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3-max-thinking", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, - "supports_vision": false + "supports_vision": false, + "supports_web_search": false }, "openrouter/qwen/qwen3-coder-next": { "input_cost_per_token": 1.2e-07, @@ -66126,12 +66941,16 @@ "max_output_tokens": 235929, "max_tokens": 235929, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3-coder-next", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_response_schema": true, "supports_vision": false, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_web_search": false }, "openrouter/minimax/minimax-m2-her": { "input_cost_per_token": 3e-07, @@ -66142,11 +66961,16 @@ "max_output_tokens": 2048, "max_tokens": 2048, "mode": "chat", - "source": "https://openrouter.ai/minimax/minimax-m2-her", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": false, + "supports_pdf_input": false, "supports_tool_choice": false, "supports_vision": false, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": false, + "supports_web_search": false }, "openrouter/openai/gpt-audio": { "input_cost_per_token": 2.5e-06, @@ -66158,12 +66982,16 @@ "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", - "source": "https://openrouter.ai/openai/gpt-audio", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_response_schema": true, "supports_vision": false, - "supports_audio_input": true + "supports_audio_input": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_web_search": false }, "openrouter/openai/gpt-audio-mini": { "input_cost_per_token": 6e-07, @@ -66175,29 +67003,36 @@ "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", - "source": "https://openrouter.ai/openai/gpt-audio-mini", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_response_schema": true, "supports_vision": false, - "supports_audio_input": true + "supports_audio_input": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_web_search": false }, "openrouter/nvidia/nemotron-3-nano-30b-a3b": { - "input_cost_per_token": 5e-08, - "output_cost_per_token": 2e-07, + "input_cost_per_token": 6e-08, + "output_cost_per_token": 2.4e-07, "cache_read_input_token_cost": 3e-08, "litellm_provider": "openrouter", "max_input_tokens": 262144, "max_output_tokens": 235929, "max_tokens": 235929, "mode": "chat", - "source": "https://openrouter.ai/nvidia/nemotron-3-nano-30b-a3b", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": false, - "supports_prompt_caching": true + "supports_prompt_caching": false, + "supports_web_search": false }, "openrouter/z-ai/glm-4.6v": { "input_cost_per_token": 3e-07, @@ -66208,19 +67043,23 @@ "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", - "source": "https://openrouter.ai/z-ai/glm-4.6v", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/google/gemini-3-pro-image-preview": { "input_cost_per_token": 2e-06, "output_cost_per_token": 1.2e-05, "cache_read_input_token_cost": 2e-07, "cache_creation_input_token_cost": 3.75e-07, + "cache_read_input_audio_token_cost": 2e-07, "input_cost_per_audio_token": 2e-06, "output_cost_per_image_token": 0.00012, "litellm_provider": "openrouter", @@ -66228,13 +67067,16 @@ "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", - "source": "https://openrouter.ai/google/gemini-3-pro-image-preview", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": false, + "supports_pdf_input": false, "supports_tool_choice": false, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/openai/gpt-5.1-codex": { "input_cost_per_token": 1.25e-06, @@ -66245,13 +67087,16 @@ "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "source": "https://openrouter.ai/openai/gpt-5.1-codex", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/openai/gpt-5.1-codex-mini": { "input_cost_per_token": 2.5e-07, @@ -66262,13 +67107,16 @@ "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "source": "https://openrouter.ai/openai/gpt-5.1-codex-mini", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/moonshotai/kimi-k2-thinking": { "input_cost_per_token": 6e-07, @@ -66276,16 +67124,19 @@ "cache_read_input_token_cost": 1.5e-07, "litellm_provider": "openrouter", "max_input_tokens": 262144, - "max_output_tokens": 100352, - "max_tokens": 100352, + "max_output_tokens": 98304, + "max_tokens": 98304, "mode": "chat", - "source": "https://openrouter.ai/moonshotai/kimi-k2-thinking", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": false, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/mistralai/voxtral-small-24b-2507": { "input_cost_per_token": 1e-07, @@ -66297,14 +67148,16 @@ "max_output_tokens": 26214, "max_tokens": 26214, "mode": "chat", - "source": "https://openrouter.ai/mistralai/voxtral-small-24b-2507", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_response_schema": true, "supports_vision": false, "supports_pdf_input": true, "supports_audio_input": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_web_search": false }, "openrouter/openai/gpt-oss-safeguard-20b": { "input_cost_per_token": 7.5e-08, @@ -66315,13 +67168,16 @@ "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", - "source": "https://openrouter.ai/openai/gpt-oss-safeguard-20b", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": false, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/qwen/qwen3-vl-32b-instruct": { "input_cost_per_token": 1.04e-07, @@ -66331,11 +67187,16 @@ "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3-vl-32b-instruct", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, "supports_tool_choice": true, "supports_response_schema": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/qwen/qwen3-vl-8b-thinking": { "input_cost_per_token": 1.8e-07, @@ -66345,12 +67206,16 @@ "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3-vl-8b-thinking", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/qwen/qwen3-vl-8b-instruct": { "input_cost_per_token": 1.17e-07, @@ -66360,17 +67225,23 @@ "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3-vl-8b-instruct", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, "supports_tool_choice": true, "supports_response_schema": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/google/gemini-2.5-flash-image": { "input_cost_per_token": 3e-07, "output_cost_per_token": 2.5e-06, "cache_read_input_token_cost": 3e-08, "cache_creation_input_token_cost": 8.33333333333333e-08, + "cache_read_input_audio_token_cost": 1e-07, "input_cost_per_audio_token": 1e-06, "output_cost_per_image_token": 3e-05, "litellm_provider": "openrouter", @@ -66378,12 +67249,16 @@ "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", - "source": "https://openrouter.ai/google/gemini-2.5-flash-image", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": false, + "supports_pdf_input": false, "supports_tool_choice": false, "supports_response_schema": true, "supports_vision": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_web_search": false }, "openrouter/qwen/qwen3-vl-30b-a3b-thinking": { "input_cost_per_token": 2e-07, @@ -66393,26 +67268,35 @@ "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3-vl-30b-a3b-thinking", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/qwen/qwen3-vl-30b-a3b-instruct": { "input_cost_per_token": 1.3e-07, "output_cost_per_token": 5.2e-07, "litellm_provider": "openrouter", "max_input_tokens": 262144, - "max_output_tokens": 16384, - "max_tokens": 16384, + "max_output_tokens": 32768, + "max_tokens": 32768, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3-vl-30b-a3b-instruct", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, "supports_tool_choice": true, "supports_response_schema": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/openai/gpt-5-pro": { "input_cost_per_token": 1.5e-05, @@ -66422,13 +67306,16 @@ "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "source": "https://openrouter.ai/openai/gpt-5-pro", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, - "supports_pdf_input": true + "supports_pdf_input": true, + "supports_prompt_caching": false, + "supports_web_search": false }, "openrouter/qwen/qwen3-vl-235b-a22b-thinking": { "input_cost_per_token": 4e-07, @@ -66438,12 +67325,16 @@ "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3-vl-235b-a22b-thinking", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/qwen/qwen3-vl-235b-a22b-instruct": { "input_cost_per_token": 2.1e-07, @@ -66454,12 +67345,16 @@ "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3-vl-235b-a22b-instruct", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_response_schema": true, "supports_vision": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_web_search": false }, "openrouter/qwen/qwen3-max": { "input_cost_per_token": 7.8e-07, @@ -66475,12 +67370,16 @@ "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3-max", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_response_schema": true, "supports_vision": false, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_web_search": false }, "openrouter/deepseek/deepseek-v3.1-terminus": { "input_cost_per_token": 2.7e-07, @@ -66491,13 +67390,16 @@ "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", - "source": "https://openrouter.ai/deepseek/deepseek-v3.1-terminus", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": false, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/qwen/qwen3-coder-flash": { "input_cost_per_token": 1.95e-07, @@ -66513,12 +67415,16 @@ "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3-coder-flash", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_response_schema": true, "supports_vision": false, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_web_search": false }, "openrouter/qwen/qwen3-next-80b-a3b-thinking": { "input_cost_per_token": 1.5e-07, @@ -66528,12 +67434,16 @@ "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3-next-80b-a3b-thinking", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, - "supports_vision": false + "supports_vision": false, + "supports_web_search": false }, "openrouter/qwen/qwen3-next-80b-a3b-instruct": { "input_cost_per_token": 9e-08, @@ -66541,17 +67451,23 @@ "cache_read_input_token_cost": 7e-08, "litellm_provider": "openrouter", "max_input_tokens": 262144, - "max_output_tokens": 235929, - "max_tokens": 235929, + "max_output_tokens": 16384, + "max_tokens": 16384, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3-next-80b-a3b-instruct", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_response_schema": true, "supports_vision": false, - "supports_prompt_caching": true + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_web_search": false }, "openrouter/qwen/qwen-plus-2025-07-28": { + "cache_creation_input_token_cost": 3.25e-07, + "cache_read_input_token_cost": 5.2e-08, "input_cost_per_token": 2.6e-07, "output_cost_per_token": 7.8e-07, "input_cost_per_token_above_256k_tokens": 7.8e-07, @@ -66561,25 +67477,35 @@ "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen-plus-2025-07-28", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": false, "supports_tool_choice": true, "supports_response_schema": true, - "supports_vision": false + "supports_vision": false, + "supports_web_search": false }, "openrouter/moonshotai/kimi-k2-0905": { "input_cost_per_token": 6e-07, "output_cost_per_token": 2.5e-06, "litellm_provider": "openrouter", "max_input_tokens": 262144, - "max_output_tokens": 100352, - "max_tokens": 100352, + "max_output_tokens": 98304, + "max_tokens": 98304, "mode": "chat", - "source": "https://openrouter.ai/moonshotai/kimi-k2-0905", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, "supports_tool_choice": true, "supports_response_schema": true, - "supports_vision": false + "supports_vision": false, + "supports_web_search": false }, "openrouter/qwen/qwen3-30b-a3b-thinking-2507": { "input_cost_per_token": 2e-07, @@ -66589,12 +67515,16 @@ "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3-30b-a3b-thinking-2507", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, - "supports_vision": false + "supports_vision": false, + "supports_web_search": false }, "openrouter/mistralai/mistral-medium-3.1": { "input_cost_per_token": 4e-07, @@ -66605,13 +67535,16 @@ "max_output_tokens": 104857, "max_tokens": 104857, "mode": "chat", - "source": "https://openrouter.ai/mistralai/mistral-medium-3.1", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_response_schema": true, "supports_vision": true, "supports_pdf_input": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_web_search": false }, "openrouter/z-ai/glm-4.5v": { "input_cost_per_token": 6e-07, @@ -66622,13 +67555,16 @@ "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", - "source": "https://openrouter.ai/z-ai/glm-4.5v", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/mistralai/codestral-2508": { "input_cost_per_token": 3e-07, @@ -66639,13 +67575,16 @@ "max_output_tokens": 204800, "max_tokens": 204800, "mode": "chat", - "source": "https://openrouter.ai/mistralai/codestral-2508", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_response_schema": true, "supports_vision": false, "supports_pdf_input": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_web_search": false }, "openrouter/qwen/qwen3-coder-30b-a3b-instruct": { "input_cost_per_token": 7e-08, @@ -66655,11 +67594,16 @@ "max_output_tokens": 235929, "max_tokens": 235929, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3-coder-30b-a3b-instruct", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, "supports_tool_choice": true, "supports_response_schema": true, - "supports_vision": false + "supports_vision": false, + "supports_web_search": false }, "openrouter/qwen/qwen3-30b-a3b-instruct-2507": { "input_cost_per_token": 4.815e-08, @@ -66669,28 +67613,37 @@ "max_output_tokens": 32000, "max_tokens": 32000, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3-30b-a3b-instruct-2507", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, "supports_tool_choice": true, "supports_response_schema": true, - "supports_vision": false + "supports_vision": false, + "supports_web_search": false }, "openrouter/z-ai/glm-4.5": { "input_cost_per_token": 6e-07, "output_cost_per_token": 2.2e-06, "cache_read_input_token_cost": 1.1e-07, + "deprecation_date": "2026-12-31", "litellm_provider": "openrouter", "max_input_tokens": 131072, "max_output_tokens": 98304, "max_tokens": 98304, "mode": "chat", - "source": "https://openrouter.ai/z-ai/glm-4.5", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": false, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/z-ai/glm-4.5-air": { "input_cost_per_token": 1.3e-07, @@ -66701,25 +67654,35 @@ "max_output_tokens": 98304, "max_tokens": 98304, "mode": "chat", - "source": "https://openrouter.ai/z-ai/glm-4.5-air", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_vision": false, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_response_schema": false, + "supports_web_search": false }, "openrouter/moonshotai/kimi-k2": { "input_cost_per_token": 5.7e-07, "output_cost_per_token": 2.3e-06, "litellm_provider": "openrouter", "max_input_tokens": 131072, - "max_output_tokens": 100352, - "max_tokens": 100352, + "max_output_tokens": 98304, + "max_tokens": 98304, "mode": "chat", - "source": "https://openrouter.ai/moonshotai/kimi-k2", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": false, "supports_tool_choice": true, - "supports_vision": false + "supports_vision": false, + "supports_web_search": false }, "openrouter/minimax/minimax-m1": { "input_cost_per_token": 4e-07, @@ -66729,11 +67692,16 @@ "max_output_tokens": 40000, "max_tokens": 40000, "mode": "chat", - "source": "https://openrouter.ai/minimax/minimax-m1", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": true, "supports_reasoning": true, - "supports_vision": false + "supports_response_schema": false, + "supports_vision": false, + "supports_web_search": false }, "openrouter/openai/o3-pro": { "input_cost_per_token": 2e-05, @@ -66743,19 +67711,23 @@ "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", - "source": "https://openrouter.ai/openai/o3-pro", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, - "supports_pdf_input": true + "supports_pdf_input": true, + "supports_prompt_caching": false, + "supports_web_search": false }, "openrouter/google/gemini-2.5-pro-preview": { "input_cost_per_token": 1.25e-06, "output_cost_per_token": 1e-05, "cache_read_input_token_cost": 1.25e-07, "cache_creation_input_token_cost": 3.75e-07, + "cache_read_input_audio_token_cost": 1.25e-07, "input_cost_per_audio_token": 1.25e-06, "input_cost_per_token_above_200k_tokens": 2.5e-06, "output_cost_per_token_above_200k_tokens": 1.5e-05, @@ -66765,7 +67737,7 @@ "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", - "source": "https://openrouter.ai/google/gemini-2.5-pro-preview", + "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, @@ -66773,7 +67745,8 @@ "supports_vision": true, "supports_pdf_input": true, "supports_audio_input": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/mistralai/mistral-medium-3": { "input_cost_per_token": 4e-07, @@ -66784,13 +67757,16 @@ "max_output_tokens": 104857, "max_tokens": 104857, "mode": "chat", - "source": "https://openrouter.ai/mistralai/mistral-medium-3", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_response_schema": true, "supports_vision": true, "supports_pdf_input": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_web_search": false }, "openrouter/google/gemini-2.5-pro-preview-05-06": { "input_cost_per_token": 1.25e-06, @@ -66824,11 +67800,16 @@ "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", - "source": "https://openrouter.ai/meta-llama/llama-guard-4-12b", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, "supports_tool_choice": false, - "supports_response_schema": true, - "supports_vision": true + "supports_response_schema": false, + "supports_vision": true, + "supports_web_search": false }, "openrouter/qwen/qwen3-30b-a3b": { "input_cost_per_token": 1.2e-07, @@ -66838,12 +67819,16 @@ "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3-30b-a3b", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, - "supports_vision": false + "supports_vision": false, + "supports_web_search": false }, "openrouter/qwen/qwen3-8b": { "input_cost_per_token": 1.17e-07, @@ -66853,12 +67838,16 @@ "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3-8b", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, - "supports_vision": false + "supports_vision": false, + "supports_web_search": false }, "openrouter/qwen/qwen3-14b": { "input_cost_per_token": 1.2e-07, @@ -66868,12 +67857,16 @@ "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3-14b", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, - "supports_vision": false + "supports_vision": false, + "supports_web_search": false }, "openrouter/qwen/qwen3-32b": { "input_cost_per_token": 8e-08, @@ -66883,12 +67876,16 @@ "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3-32b", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, - "supports_vision": false + "supports_vision": false, + "supports_web_search": false }, "openrouter/qwen/qwen3-235b-a22b": { "input_cost_per_token": 4.55e-07, @@ -66898,12 +67895,16 @@ "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen3-235b-a22b", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, - "supports_vision": false + "supports_vision": false, + "supports_web_search": false }, "openrouter/openai/o4-mini-high": { "input_cost_per_token": 1.1e-06, @@ -66914,28 +67915,35 @@ "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", - "source": "https://openrouter.ai/openai/o4-mini-high", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, "supports_pdf_input": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_web_search": false }, "openrouter/meta-llama/llama-4-maverick": { "input_cost_per_token": 1.875e-07, "output_cost_per_token": 6.525e-07, "litellm_provider": "openrouter", "max_input_tokens": 1048576, - "max_output_tokens": 115200, - "max_tokens": 115200, + "max_output_tokens": 16384, + "max_tokens": 16384, "mode": "chat", - "source": "https://openrouter.ai/meta-llama/llama-4-maverick", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, "supports_tool_choice": true, "supports_response_schema": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/meta-llama/llama-4-scout": { "input_cost_per_token": 1e-07, @@ -66945,11 +67953,16 @@ "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", - "source": "https://openrouter.ai/meta-llama/llama-4-scout", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, "supports_tool_choice": true, "supports_response_schema": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/openai/o1-pro": { "input_cost_per_token": 0.00015, @@ -66959,13 +67972,16 @@ "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", - "source": "https://openrouter.ai/openai/o1-pro", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": false, "supports_tool_choice": false, "supports_reasoning": true, "supports_response_schema": true, "supports_vision": true, - "supports_pdf_input": true + "supports_pdf_input": true, + "supports_prompt_caching": false, + "supports_web_search": false }, "openrouter/google/gemma-3-4b-it": { "input_cost_per_token": 5e-08, @@ -66975,11 +67991,16 @@ "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", - "source": "https://openrouter.ai/google/gemma-3-4b-it", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, "supports_tool_choice": false, "supports_response_schema": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/google/gemma-3-12b-it": { "input_cost_per_token": 5e-08, @@ -66989,11 +68010,16 @@ "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", - "source": "https://openrouter.ai/google/gemma-3-12b-it", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, "supports_tool_choice": true, "supports_response_schema": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/google/gemma-3-27b-it": { "input_cost_per_token": 8e-08, @@ -67004,12 +68030,16 @@ "max_output_tokens": 117964, "max_tokens": 117964, "mode": "chat", - "source": "https://openrouter.ai/google/gemma-3-27b-it", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_response_schema": true, "supports_vision": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_web_search": false }, "openrouter/mistralai/mistral-saba": { "input_cost_per_token": 2e-07, @@ -67020,13 +68050,16 @@ "max_output_tokens": 26214, "max_tokens": 26214, "mode": "chat", - "source": "https://openrouter.ai/mistralai/mistral-saba", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_response_schema": true, "supports_vision": false, "supports_pdf_input": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_web_search": false }, "openrouter/qwen/qwen2.5-vl-72b-instruct": { "input_cost_per_token": 8e-07, @@ -67037,12 +68070,16 @@ "max_output_tokens": 115200, "max_tokens": 115200, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen2.5-vl-72b-instruct", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": false, + "supports_pdf_input": false, "supports_tool_choice": false, "supports_response_schema": true, "supports_vision": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_web_search": false }, "openrouter/qwen/qwen-plus": { "input_cost_per_token": 2.6e-07, @@ -67058,12 +68095,16 @@ "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen-plus", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_response_schema": true, "supports_vision": false, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_web_search": false }, "openrouter/mistralai/mistral-small-24b-instruct-2501": { "input_cost_per_token": 5e-08, @@ -67073,11 +68114,16 @@ "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", - "source": "https://openrouter.ai/mistralai/mistral-small-24b-instruct-2501", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, "supports_tool_choice": false, "supports_response_schema": true, - "supports_vision": false + "supports_vision": false, + "supports_web_search": false }, "openrouter/deepseek/deepseek-r1-distill-llama-70b": { "input_cost_per_token": 8e-07, @@ -67087,11 +68133,16 @@ "max_output_tokens": 7372, "max_tokens": 7372, "mode": "chat", - "source": "https://openrouter.ai/deepseek/deepseek-r1-distill-llama-70b", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, "supports_tool_choice": false, "supports_reasoning": true, - "supports_vision": false + "supports_response_schema": false, + "supports_vision": false, + "supports_web_search": false }, "openrouter/minimax/minimax-01": { "input_cost_per_token": 2e-07, @@ -67101,10 +68152,16 @@ "max_output_tokens": 900172, "max_tokens": 900172, "mode": "chat", - "source": "https://openrouter.ai/minimax/minimax-01", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": false, "supports_tool_choice": false, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/meta-llama/llama-3.3-70b-instruct": { "input_cost_per_token": 1e-07, @@ -67114,11 +68171,16 @@ "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", - "source": "https://openrouter.ai/meta-llama/llama-3.3-70b-instruct", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, "supports_tool_choice": true, "supports_response_schema": true, - "supports_vision": false + "supports_vision": false, + "supports_web_search": false }, "openrouter/openai/gpt-4o-2024-11-20": { "input_cost_per_token": 2.5e-06, @@ -67129,14 +68191,16 @@ "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", - "source": "https://openrouter.ai/openai/gpt-4o-2024-11-20", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_response_schema": true, "supports_web_search": true, "supports_vision": true, "supports_pdf_input": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_reasoning": false }, "openrouter/mistralai/mistral-large-2407": { "input_cost_per_token": 2e-06, @@ -67147,13 +68211,16 @@ "max_output_tokens": 104857, "max_tokens": 104857, "mode": "chat", - "source": "https://openrouter.ai/mistralai/mistral-large-2407", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_response_schema": true, "supports_vision": false, "supports_pdf_input": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_web_search": false }, "openrouter/qwen/qwen-2.5-7b-instruct": { "input_cost_per_token": 1e-07, @@ -67163,11 +68230,16 @@ "max_output_tokens": 29491, "max_tokens": 29491, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen-2.5-7b-instruct", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, "supports_tool_choice": true, "supports_response_schema": true, - "supports_vision": false + "supports_vision": false, + "supports_web_search": false }, "openrouter/meta-llama/llama-3.2-1b-instruct": { "input_cost_per_token": 2.7e-08, @@ -67177,10 +68249,16 @@ "max_output_tokens": 54000, "max_tokens": 54000, "mode": "chat", - "source": "https://openrouter.ai/meta-llama/llama-3.2-1b-instruct", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": false, "supports_tool_choice": false, - "supports_vision": false + "supports_vision": false, + "supports_web_search": false }, "openrouter/meta-llama/llama-3.2-3b-instruct": { "input_cost_per_token": 5e-08, @@ -67190,11 +68268,16 @@ "max_output_tokens": 117964, "max_tokens": 117964, "mode": "chat", - "source": "https://openrouter.ai/meta-llama/llama-3.2-3b-instruct", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, "supports_tool_choice": false, "supports_response_schema": true, - "supports_vision": false + "supports_vision": false, + "supports_web_search": false }, "openrouter/qwen/qwen-2.5-72b-instruct": { "input_cost_per_token": 3.6e-07, @@ -67204,11 +68287,16 @@ "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", - "source": "https://openrouter.ai/qwen/qwen-2.5-72b-instruct", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, "supports_tool_choice": true, "supports_response_schema": true, - "supports_vision": false + "supports_vision": false, + "supports_web_search": false }, "openrouter/openai/gpt-4o-2024-08-06": { "input_cost_per_token": 2.5e-06, @@ -67219,14 +68307,16 @@ "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", - "source": "https://openrouter.ai/openai/gpt-4o-2024-08-06", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_response_schema": true, "supports_web_search": true, "supports_vision": true, "supports_pdf_input": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_reasoning": false }, "openrouter/meta-llama/llama-3.1-70b-instruct": { "input_cost_per_token": 4e-07, @@ -67236,11 +68326,16 @@ "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", - "source": "https://openrouter.ai/meta-llama/llama-3.1-70b-instruct", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, "supports_tool_choice": true, "supports_response_schema": true, - "supports_vision": false + "supports_vision": false, + "supports_web_search": false }, "openrouter/meta-llama/llama-3.1-8b-instruct": { "input_cost_per_token": 5e-08, @@ -67251,12 +68346,16 @@ "max_output_tokens": 117964, "max_tokens": 117964, "mode": "chat", - "source": "https://openrouter.ai/meta-llama/llama-3.1-8b-instruct", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, "supports_tool_choice": true, "supports_response_schema": true, "supports_vision": false, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_web_search": false }, "openrouter/mistralai/mistral-nemo": { "input_cost_per_token": 1.9e-08, @@ -67266,11 +68365,16 @@ "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", - "source": "https://openrouter.ai/mistralai/mistral-nemo", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, "supports_tool_choice": true, "supports_response_schema": true, - "supports_vision": false + "supports_vision": false, + "supports_web_search": false }, "openrouter/openai/gpt-4o-mini-2024-07-18": { "input_cost_per_token": 1.5e-07, @@ -67281,14 +68385,16 @@ "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", - "source": "https://openrouter.ai/openai/gpt-4o-mini-2024-07-18", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, "supports_tool_choice": true, "supports_response_schema": true, "supports_web_search": true, "supports_vision": true, "supports_pdf_input": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "supports_reasoning": false }, "openrouter/google/gemma-2-27b-it": { "input_cost_per_token": 6.5e-07, @@ -67298,11 +68404,16 @@ "max_output_tokens": 2048, "max_tokens": 2048, "mode": "chat", - "source": "https://openrouter.ai/google/gemma-2-27b-it", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, "supports_tool_choice": false, "supports_response_schema": true, - "supports_vision": false + "supports_vision": false, + "supports_web_search": false }, "openrouter/openai/gpt-4-turbo": { "input_cost_per_token": 1e-05, @@ -67312,11 +68423,16 @@ "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", - "source": "https://openrouter.ai/openai/gpt-4-turbo", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, "supports_tool_choice": true, "supports_response_schema": true, - "supports_vision": true + "supports_vision": true, + "supports_web_search": false }, "openrouter/openai/gpt-4-turbo-preview": { "input_cost_per_token": 1e-05, @@ -67340,11 +68456,16 @@ "max_output_tokens": 3685, "max_tokens": 3685, "mode": "chat", - "source": "https://openrouter.ai/openai/gpt-3.5-turbo-instruct", + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, "supports_tool_choice": false, "supports_response_schema": true, - "supports_vision": false + "supports_vision": false, + "supports_web_search": false }, "together_ai/arcee-ai/trinity-mini": { "input_cost_per_token": 4.5e-08, @@ -69394,5 +70515,3913 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true + }, + "openrouter/~anthropic/claude-fable-latest": { + "cache_creation_input_token_cost": 1.25e-05, + "cache_creation_input_token_cost_above_1hr": 2e-05, + "cache_read_input_token_cost": 2.5e-07, + "input_cost_per_token": 1e-05, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/~anthropic/claude-haiku-latest": { + "cache_creation_input_token_cost": 1.25e-06, + "cache_creation_input_token_cost_above_1hr": 2e-06, + "cache_read_input_token_cost": 1e-07, + "input_cost_per_token": 1e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 5e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/~anthropic/claude-opus-latest": { + "cache_creation_input_token_cost": 6.25e-06, + "cache_creation_input_token_cost_above_1hr": 1e-05, + "cache_read_input_token_cost": 5e-07, + "input_cost_per_token": 5e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2.5e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/~anthropic/claude-sonnet-latest": { + "cache_creation_input_token_cost": 2.5e-06, + "cache_creation_input_token_cost_above_1hr": 4e-06, + "cache_read_input_token_cost": 2e-07, + "input_cost_per_token": 2e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/~deepseek/deepseek-flash-latest": { + "cache_read_input_token_cost": 1.5e-08, + "input_cost_per_token": 1.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 393216, + "max_tokens": 393216, + "mode": "chat", + "output_cost_per_token": 6e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/~deepseek/deepseek-pro-latest": { + "cache_read_input_token_cost": 2.2e-08, + "input_cost_per_token": 6.6e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 393216, + "max_tokens": 393216, + "mode": "chat", + "output_cost_per_token": 1.98e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/~deepseek/deepseek-v4-flash-latest": { + "cache_read_input_token_cost": 8.8e-09, + "input_cost_per_token": 5.58e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 1310720, + "max_output_tokens": 393216, + "max_tokens": 393216, + "mode": "chat", + "output_cost_per_token": 1.767e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/~google/gemini-flash-latest": { + "cache_creation_input_token_cost": 4.16666666666667e-08, + "cache_read_input_audio_token_cost": 7.5e-08, + "cache_read_input_token_cost": 7.5e-08, + "input_cost_per_audio_token": 7.5e-07, + "input_cost_per_token": 7.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 3.75e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/~google/gemini-pro-latest": { + "cache_creation_input_token_cost": 3.75e-07, + "cache_read_input_audio_token_cost": 2e-07, + "cache_read_input_token_cost": 2e-07, + "cache_read_input_token_cost_above_200k_tokens": 4e-07, + "input_cost_per_audio_token": 2e-06, + "input_cost_per_token": 2e-06, + "input_cost_per_token_above_200k_tokens": 4e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 1.2e-05, + "output_cost_per_token_above_200k_tokens": 1.8e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/~moonshotai/kimi-latest": { + "cache_read_input_token_cost": 2.3e-07, + "input_cost_per_token": 2.1e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 943718, + "max_tokens": 943718, + "mode": "chat", + "output_cost_per_token": 1.095e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/~openai/gpt-astra-latest": { + "cache_creation_input_token_cost": 1.25e-05, + "cache_creation_input_token_cost_above_272k_tokens": 2.5e-05, + "cache_read_input_token_cost": 1e-06, + "cache_read_input_token_cost_above_272k_tokens": 2e-06, + "input_cost_per_token": 1e-05, + "input_cost_per_token_above_272k_tokens": 2e-05, + "litellm_provider": "openrouter", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5e-05, + "output_cost_per_token_above_272k_tokens": 7.5e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/~openai/gpt-luna-latest": { + "cache_creation_input_token_cost": 2.5e-07, + "cache_creation_input_token_cost_above_272k_tokens": 5e-07, + "cache_read_input_token_cost": 2e-08, + "cache_read_input_token_cost_above_272k_tokens": 4e-08, + "input_cost_per_token": 2e-07, + "input_cost_per_token_above_272k_tokens": 4e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.2e-06, + "output_cost_per_token_above_272k_tokens": 1.8e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/~openai/gpt-mini-latest": { + "cache_read_input_token_cost": 7.5e-08, + "input_cost_per_token": 7.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 400000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 4.5e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/~openai/gpt-sol-latest": { + "cache_creation_input_token_cost": 2.5e-06, + "cache_creation_input_token_cost_above_272k_tokens": 5e-06, + "cache_read_input_token_cost": 2e-07, + "cache_read_input_token_cost_above_272k_tokens": 4e-07, + "input_cost_per_token": 2e-06, + "input_cost_per_token_above_272k_tokens": 4e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1e-05, + "output_cost_per_token_above_272k_tokens": 1.5e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/~openai/gpt-terra-latest": { + "cache_creation_input_token_cost": 2.5e-06, + "cache_creation_input_token_cost_above_272k_tokens": 5e-06, + "cache_read_input_token_cost": 2e-07, + "cache_read_input_token_cost_above_272k_tokens": 4e-07, + "input_cost_per_token": 2e-06, + "input_cost_per_token_above_272k_tokens": 4e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.2e-05, + "output_cost_per_token_above_272k_tokens": 1.8e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/~x-ai/grok-latest": { + "cache_read_input_token_cost": 5e-07, + "cache_read_input_token_cost_above_200k_tokens": 1e-06, + "input_cost_per_token": 2e-06, + "input_cost_per_token_above_200k_tokens": 4e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 500000, + "max_output_tokens": 450000, + "max_tokens": 450000, + "mode": "chat", + "output_cost_per_token": 6e-06, + "output_cost_per_token_above_200k_tokens": 1.2e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/~z-ai/glm-flash-latest": { + "cache_read_input_token_cost": 1.5e-08, + "input_cost_per_token": 7.5e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 1310720, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 2.5e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/~z-ai/glm-latest": { + "cache_read_input_token_cost": 1.755e-07, + "input_cost_per_token": 8.775e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1310720, + "max_output_tokens": 235929, + "max_tokens": 235929, + "mode": "chat", + "output_cost_per_token": 2.97e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/aion-labs/aion-2.0": { + "cache_read_input_token_cost": 2e-07, + "input_cost_per_token": 8e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 1.6e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/aion-labs/aion-3.0": { + "cache_read_input_token_cost": 7.5e-07, + "input_cost_per_token": 3e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 6e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/aion-labs/aion-3.0-mini": { + "cache_read_input_token_cost": 1.8e-07, + "input_cost_per_token": 7e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 1.4e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/aion-labs/aion-rp-llama-3.1-8b": { + "input_cost_per_token": 8e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 32768, + "max_output_tokens": 29491, + "max_tokens": 29491, + "mode": "chat", + "output_cost_per_token": 1.6e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": false, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/amazon/nova-2-lite-v1": { + "input_cost_per_token": 3e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 65535, + "max_tokens": 65535, + "mode": "chat", + "output_cost_per_token": 2.5e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/amazon/nova-lite-v1": { + "input_cost_per_token": 6e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 300000, + "max_output_tokens": 5120, + "max_tokens": 5120, + "mode": "chat", + "output_cost_per_token": 2.4e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": false, + "supports_tool_choice": false, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/amazon/nova-micro-v1": { + "input_cost_per_token": 3.5e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 128000, + "max_output_tokens": 5120, + "max_tokens": 5120, + "mode": "chat", + "output_cost_per_token": 1.4e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": false, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/amazon/nova-premier-v1": { + "cache_read_input_token_cost": 6.25e-07, + "input_cost_per_token": 2.5e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 1.25e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": false, + "supports_tool_choice": false, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/amazon/nova-pro-v1": { + "input_cost_per_token": 8e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 300000, + "max_output_tokens": 5120, + "max_tokens": 5120, + "mode": "chat", + "output_cost_per_token": 3.2e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": false, + "supports_tool_choice": false, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/anthracite-org/magnum-v4-72b": { + "input_cost_per_token": 2.5e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 32768, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 5e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/anthropic/claude-fable-5:batch": { + "cache_creation_input_token_cost": 6.25e-06, + "cache_creation_input_token_cost_above_1hr": 1e-05, + "cache_read_input_token_cost": 5e-07, + "input_cost_per_token": 5e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2.5e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/anthropic/claude-fable-5.1:batch": { + "cache_creation_input_token_cost": 6.25e-06, + "cache_creation_input_token_cost_above_1hr": 1e-05, + "cache_read_input_token_cost": 1.25e-07, + "input_cost_per_token": 5e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2.5e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/anthropic/claude-haiku-4.5:batch": { + "cache_creation_input_token_cost": 6.25e-07, + "cache_creation_input_token_cost_above_1hr": 1e-06, + "cache_read_input_token_cost": 5e-08, + "input_cost_per_token": 5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 2.5e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/anthropic/claude-opus-4.1:batch": { + "cache_creation_input_token_cost": 9.375e-06, + "cache_creation_input_token_cost_above_1hr": 1.5e-05, + "cache_read_input_token_cost": 7.5e-07, + "input_cost_per_token": 7.5e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 3.75e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/anthropic/claude-opus-4.5:batch": { + "cache_creation_input_token_cost": 3.125e-06, + "cache_creation_input_token_cost_above_1hr": 5e-06, + "cache_read_input_token_cost": 2.5e-07, + "input_cost_per_token": 2.5e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 1.25e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/anthropic/claude-opus-4.6:batch": { + "cache_creation_input_token_cost": 3.125e-06, + "cache_creation_input_token_cost_above_1hr": 5e-06, + "cache_read_input_token_cost": 2.5e-07, + "input_cost_per_token": 2.5e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.25e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/anthropic/claude-opus-4.7:batch": { + "cache_creation_input_token_cost": 3.125e-06, + "cache_creation_input_token_cost_above_1hr": 5e-06, + "cache_read_input_token_cost": 2.5e-07, + "input_cost_per_token": 2.5e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.25e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/anthropic/claude-opus-4.8:batch": { + "cache_creation_input_token_cost": 3.125e-06, + "cache_creation_input_token_cost_above_1hr": 5e-06, + "cache_read_input_token_cost": 2.5e-07, + "input_cost_per_token": 2.5e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.25e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/anthropic/claude-opus-5:batch": { + "cache_creation_input_token_cost": 3.125e-06, + "cache_creation_input_token_cost_above_1hr": 5e-06, + "cache_read_input_token_cost": 2.5e-07, + "input_cost_per_token": 2.5e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.25e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/anthropic/claude-sonnet-4.5:batch": { + "cache_creation_input_token_cost": 1.875e-06, + "cache_creation_input_token_cost_above_1hr": 3e-06, + "cache_creation_input_token_cost_above_200k_tokens": 3.75e-06, + "cache_read_input_token_cost": 1.5e-07, + "cache_read_input_token_cost_above_200k_tokens": 3e-07, + "input_cost_per_token": 1.5e-06, + "input_cost_per_token_above_200k_tokens": 3e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 7.5e-06, + "output_cost_per_token_above_200k_tokens": 1.125e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/anthropic/claude-sonnet-4.6:batch": { + "cache_creation_input_token_cost": 1.875e-06, + "cache_creation_input_token_cost_above_1hr": 3e-06, + "cache_read_input_token_cost": 1.5e-07, + "input_cost_per_token": 1.5e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 7.5e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/anthropic/claude-sonnet-5:batch": { + "cache_creation_input_token_cost": 1.25e-06, + "cache_creation_input_token_cost_above_1hr": 2e-06, + "cache_read_input_token_cost": 1e-07, + "input_cost_per_token": 1e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/arcee-ai/trinity-large-thinking": { + "cache_read_input_token_cost": 6e-08, + "input_cost_per_token": 2.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 80000, + "max_tokens": 80000, + "mode": "chat", + "output_cost_per_token": 8e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/baidu/ernie-4.5-vl-424b-a47b": { + "input_cost_per_token": 4.2e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 123000, + "max_output_tokens": 16000, + "max_tokens": 16000, + "mode": "chat", + "output_cost_per_token": 1.25e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_tool_choice": false, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/bytedance-seed/seed-1.6": { + "input_cost_per_token": 2.5e-07, + "input_cost_per_token_above_128k_tokens": 5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 2e-06, + "output_cost_per_token_above_128k_tokens": 4e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/bytedance-seed/seed-1.6-flash": { + "input_cost_per_token": 7.5e-08, + "input_cost_per_token_above_128k_tokens": 1e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 3e-07, + "output_cost_per_token_above_128k_tokens": 8e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/bytedance-seed/seed-2-1-turbo": { + "input_cost_per_token": 5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 235929, + "max_tokens": 235929, + "mode": "chat", + "output_cost_per_token": 2.5e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/bytedance-seed/seed-2.0-code": { + "input_cost_per_token": 5e-07, + "input_cost_per_token_above_128k_tokens": 1e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 3e-06, + "output_cost_per_token_above_128k_tokens": 6e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/bytedance-seed/seed-2.0-lite": { + "input_cost_per_token": 2.5e-07, + "input_cost_per_token_above_128k_tokens": 5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 2e-06, + "output_cost_per_token_above_128k_tokens": 4e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/bytedance-seed/seed-2.0-mini": { + "input_cost_per_token": 1e-07, + "input_cost_per_token_above_128k_tokens": 2e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 4e-07, + "output_cost_per_token_above_128k_tokens": 8e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/cognitivecomputations/dolphin-mistral-24b-venice-edition": { + "input_cost_per_token": 2e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 128000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 9e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/cohere/command-a": { + "input_cost_per_token": 2.5e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 256000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/cohere/command-r-08-2024": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 128000, + "max_output_tokens": 4000, + "max_tokens": 4000, + "mode": "chat", + "output_cost_per_token": 6e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/cohere/command-r-plus-08-2024": { + "input_cost_per_token": 2.5e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 128000, + "max_output_tokens": 4000, + "max_tokens": 4000, + "mode": "chat", + "output_cost_per_token": 1e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/cohere/command-r7b-12-2024": { + "input_cost_per_token": 3.75e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 128000, + "max_output_tokens": 4000, + "max_tokens": 4000, + "mode": "chat", + "output_cost_per_token": 1.5e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/cohere/north-mini-code:free": { + "input_cost_per_token": 0.0, + "litellm_provider": "openrouter", + "max_input_tokens": 256000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.0, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/deepseek/deepseek-v4-flash-0731:batch": { + "cache_read_input_token_cost": 3.5e-09, + "input_cost_per_token": 1.1e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 943718, + "max_tokens": 943718, + "mode": "chat", + "output_cost_per_token": 3.3e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/deepseek/deepseek-v4-flash-0731:free": { + "input_cost_per_token": 0.0, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 393216, + "max_tokens": 393216, + "mode": "chat", + "output_cost_per_token": 0.0, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/deepseek/deepseek-v4-flash-vision-exp:batch": { + "cache_read_input_token_cost": 3.5e-09, + "input_cost_per_token": 1.1e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 943718, + "max_tokens": 943718, + "mode": "chat", + "output_cost_per_token": 3.3e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/deepseek/deepseek-v4-pro-0813:batch": { + "cache_read_input_token_cost": 2.2e-08, + "input_cost_per_token": 6.6e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 943718, + "max_tokens": 943718, + "mode": "chat", + "output_cost_per_token": 1.98e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/dots-studio/dots-3-note-preview:free": { + "deprecation_date": "2026-09-30", + "input_cost_per_token": 0.0, + "litellm_provider": "openrouter", + "max_input_tokens": 512000, + "max_output_tokens": 460800, + "max_tokens": 460800, + "mode": "chat", + "output_cost_per_token": 0.0, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/google/gemini-2.5-flash-lite:batch": { + "cache_read_input_audio_token_cost": 3e-08, + "cache_read_input_token_cost": 1e-08, + "input_cost_per_audio_token": 1.5e-07, + "input_cost_per_token": 5e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_tokens": 65535, + "mode": "chat", + "output_cost_per_token": 2e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/google/gemini-2.5-flash:batch": { + "cache_read_input_audio_token_cost": 1e-07, + "cache_read_input_token_cost": 3e-08, + "input_cost_per_audio_token": 5e-07, + "input_cost_per_token": 1.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_tokens": 65535, + "mode": "chat", + "output_cost_per_token": 1.25e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/google/gemini-2.5-pro:batch": { + "cache_read_input_audio_token_cost": 1.25e-07, + "cache_read_input_token_cost": 1.25e-07, + "cache_read_input_token_cost_above_200k_tokens": 2.5e-07, + "input_cost_per_audio_token": 6.25e-07, + "input_cost_per_token": 6.25e-07, + "input_cost_per_token_above_200k_tokens": 1.25e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 5e-06, + "output_cost_per_token_above_200k_tokens": 7.5e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/google/gemini-3-flash-preview:batch": { + "input_cost_per_audio_token": 5e-07, + "input_cost_per_token": 2.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 1.5e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/google/gemini-3.1-flash-lite:batch": { + "cache_read_input_audio_token_cost": 2.5e-08, + "cache_read_input_token_cost": 1.25e-08, + "input_cost_per_audio_token": 2.5e-07, + "input_cost_per_token": 1.25e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 7.5e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/google/gemini-3.1-pro-preview:batch": { + "input_cost_per_audio_token": 1e-06, + "input_cost_per_token": 1e-06, + "input_cost_per_token_above_200k_tokens": 2e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 6e-06, + "output_cost_per_token_above_200k_tokens": 9e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/google/gemini-3.5-flash-lite:batch": { + "cache_read_input_audio_token_cost": 1.5e-08, + "cache_read_input_token_cost": 1.5e-08, + "input_cost_per_audio_token": 1.5e-07, + "input_cost_per_token": 1.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 1.25e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/google/gemini-3.5-flash:batch": { + "cache_read_input_audio_token_cost": 1.5e-07, + "cache_read_input_token_cost": 7.5e-08, + "input_cost_per_audio_token": 1.5e-06, + "input_cost_per_token": 7.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 4.5e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/google/gemini-3.6-flash:batch": { + "cache_creation_input_token_cost": 4.16666666666667e-08, + "cache_read_input_audio_token_cost": 3.75e-08, + "cache_read_input_token_cost": 3.75e-08, + "input_cost_per_audio_token": 3.75e-07, + "input_cost_per_token": 3.75e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 1.875e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/google/gemini-3.7-flash:batch": { + "cache_creation_input_token_cost": 4.16666666666667e-08, + "cache_read_input_audio_token_cost": 3.75e-08, + "cache_read_input_token_cost": 3.75e-08, + "input_cost_per_audio_token": 3.75e-07, + "input_cost_per_token": 3.75e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 1.875e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/google/gemini-3.8-flash:batch": { + "cache_creation_input_token_cost": 4.16666666666667e-08, + "cache_read_input_audio_token_cost": 3.75e-08, + "cache_read_input_token_cost": 3.75e-08, + "input_cost_per_audio_token": 3.75e-07, + "input_cost_per_token": 3.75e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 1.875e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/ibm-granite/granite-4.0-h-micro": { + "input_cost_per_token": 1.7e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 131000, + "max_output_tokens": 117900, + "max_tokens": 117900, + "mode": "chat", + "output_cost_per_token": 1.12e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/ibm-granite/granite-4.2-8b": { + "cache_read_input_token_cost": 1.5e-08, + "input_cost_per_token": 6e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 117964, + "max_tokens": 117964, + "mode": "chat", + "output_cost_per_token": 2.5e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/inception/mercury-2": { + "cache_read_input_token_cost": 2.5e-08, + "input_cost_per_token": 2.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 128000, + "max_output_tokens": 50000, + "max_tokens": 50000, + "mode": "chat", + "output_cost_per_token": 7.5e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/inception/mercury-2.5": { + "cache_read_input_token_cost": 4e-09, + "input_cost_per_token": 4e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 260000, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 1.5e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/inclusionai/ling-3.0-flash": { + "cache_read_input_token_cost": 4.2e-09, + "input_cost_per_token": 2.1e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 6.3e-08, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/inclusionai/ling-3.0-flash-fin": { + "cache_read_input_token_cost": 1.2e-08, + "input_cost_per_token": 6e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 235929, + "max_tokens": 235929, + "mode": "chat", + "output_cost_per_token": 1.8e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/inclusionai/ling-3.0-flash-fin:free": { + "input_cost_per_token": 0.0, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 0.0, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/inclusionai/ling-3.0-flash-sante:free": { + "input_cost_per_token": 0.0, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 0.0, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/inclusionai/ling-3.0-flash-vl": { + "cache_read_input_token_cost": 1.2e-08, + "input_cost_per_token": 6e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 1.8e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/inclusionai/ling-3.0-flash-vl:free": { + "input_cost_per_token": 0.0, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 0.0, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/inference-net/schematron-v2-small": { + "cache_read_input_token_cost": 5e-08, + "input_cost_per_token": 5e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 2.3e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/inference-net/schematron-v2-turbo": { + "cache_read_input_token_cost": 3e-08, + "input_cost_per_token": 3e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 128000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.5e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/kwaipilot/kat-coder-pro-v2": { + "cache_read_input_token_cost": 6e-08, + "input_cost_per_token": 3e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 144000, + "max_tokens": 144000, + "mode": "chat", + "output_cost_per_token": 1.2e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/kwaipilot/kat-coder-pro-v2.5": { + "cache_read_input_token_cost": 1.5e-07, + "input_cost_per_token": 7.4e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 235929, + "max_tokens": 235929, + "mode": "chat", + "output_cost_per_token": 2.96e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/liquid/lfm-2.5-2.6b:free": { + "input_cost_per_token": 0.0, + "litellm_provider": "openrouter", + "max_input_tokens": 65536, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.0, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/meituan/longcat-2.0": { + "cache_read_input_token_cost": 6e-09, + "input_cost_per_token": 3e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1048756, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 1.2e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/meta/muse-glimmer-30b": { + "cache_read_input_token_cost": 4e-08, + "input_cost_per_token": 3e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 117964, + "max_tokens": 117964, + "mode": "chat", + "output_cost_per_token": 1.1e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/meta/muse-glimmer-30b:batch": { + "cache_read_input_token_cost": 2e-08, + "input_cost_per_token": 1.75e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 117964, + "max_tokens": 117964, + "mode": "chat", + "output_cost_per_token": 7.5e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/meta/muse-spark-1.1": { + "cache_read_input_token_cost": 1.5e-07, + "input_cost_per_token": 1.25e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 943718, + "max_tokens": 943718, + "mode": "chat", + "output_cost_per_token": 4.25e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/meta/muse-spark-1.2": { + "cache_read_input_token_cost": 1.5e-07, + "input_cost_per_token": 1.25e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 943718, + "max_tokens": 943718, + "mode": "chat", + "output_cost_per_token": 4.25e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/meta/muse-spark-1.2-contributor": { + "cache_read_input_token_cost": 2e-09, + "input_cost_per_token": 1e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 943718, + "max_tokens": 943718, + "mode": "chat", + "output_cost_per_token": 2e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/meta/muse-spark-1.3": { + "cache_read_input_token_cost": 1.5e-07, + "input_cost_per_token": 1.25e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 943718, + "max_tokens": 943718, + "mode": "chat", + "output_cost_per_token": 4.25e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/meta/muse-spark-1.3-contributor": { + "cache_read_input_token_cost": 2e-09, + "input_cost_per_token": 1e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 943718, + "max_tokens": 943718, + "mode": "chat", + "output_cost_per_token": 2e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/microsoft/phi-4": { + "input_cost_per_token": 7e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 16384, + "max_output_tokens": 14745, + "max_tokens": 14745, + "mode": "chat", + "output_cost_per_token": 1.4e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/microsoft/wizardlm-2-8x22b": { + "input_cost_per_token": 6.2e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 65535, + "max_output_tokens": 8000, + "max_tokens": 8000, + "mode": "chat", + "output_cost_per_token": 6.2e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/minimax/minimax-m3:batch": { + "cache_read_input_token_cost": 6e-08, + "input_cost_per_token": 3e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 524288, + "max_output_tokens": 471859, + "max_tokens": 471859, + "mode": "chat", + "output_cost_per_token": 1.2e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/mistralai/codestral-2508:batch": { + "cache_read_input_token_cost": 1.5e-08, + "input_cost_per_token": 1.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 256000, + "max_output_tokens": 204800, + "max_tokens": 204800, + "mode": "chat", + "output_cost_per_token": 4.5e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/mistralai/ministral-8b-2512:batch": { + "cache_read_input_token_cost": 7.5e-09, + "input_cost_per_token": 7.5e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 209715, + "max_tokens": 209715, + "mode": "chat", + "output_cost_per_token": 7.5e-08, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/mistralai/mistral-large-2512:batch": { + "cache_read_input_token_cost": 2.5e-08, + "input_cost_per_token": 2.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 209715, + "max_tokens": 209715, + "mode": "chat", + "output_cost_per_token": 7.5e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/mistralai/mistral-medium-3-5:batch": { + "input_cost_per_token": 7.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 209715, + "max_tokens": 209715, + "mode": "chat", + "output_cost_per_token": 3.75e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/mistralai/mistral-medium-3.1:batch": { + "cache_read_input_token_cost": 2e-08, + "input_cost_per_token": 2e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 104857, + "max_tokens": 104857, + "mode": "chat", + "output_cost_per_token": 1e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/mistralai/mistral-small-2603:batch": { + "cache_read_input_token_cost": 7.5e-09, + "input_cost_per_token": 7.5e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 209715, + "max_tokens": 209715, + "mode": "chat", + "output_cost_per_token": 3e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/moonshotai/kimi-k3:batch": { + "cache_read_input_token_cost": 3e-07, + "input_cost_per_token": 3e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 943718, + "max_tokens": 943718, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/morph/morph-v3-fast": { + "input_cost_per_token": 8e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 81920, + "max_output_tokens": 38000, + "max_tokens": 38000, + "mode": "chat", + "output_cost_per_token": 1.2e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": false, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/morph/morph-v3-large": { + "input_cost_per_token": 9e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 1.9e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/nex-agi/nex-n2.5-mini:free": { + "input_cost_per_token": 0.0, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 235929, + "max_tokens": 235929, + "mode": "chat", + "output_cost_per_token": 0.0, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/nex-agi/nex-n2.5-pro:free": { + "input_cost_per_token": 0.0, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 235929, + "max_tokens": 235929, + "mode": "chat", + "output_cost_per_token": 0.0, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/nousresearch/hermes-3-llama-3.1-405b": { + "input_cost_per_token": 1e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 1e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/nousresearch/hermes-3-llama-3.1-70b": { + "input_cost_per_token": 7e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 7e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/nousresearch/hermes-4-405b": { + "input_cost_per_token": 1e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 117964, + "max_tokens": 117964, + "mode": "chat", + "output_cost_per_token": 3e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/openai/gpt-3.5-turbo-0613": { + "input_cost_per_token": 1e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 4095, + "max_output_tokens": 3685, + "max_tokens": 3685, + "mode": "chat", + "output_cost_per_token": 2e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/openai/gpt-3.5-turbo:batch": { + "input_cost_per_token": 2.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 16385, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 7.5e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/openai/gpt-4-turbo:batch": { + "input_cost_per_token": 5e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/gpt-4.1-mini:batch": { + "cache_read_input_token_cost": 5e-08, + "input_cost_per_token": 2e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1047576, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 8e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/gpt-4.1-nano:batch": { + "cache_read_input_token_cost": 1.25e-08, + "input_cost_per_token": 5e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 1047576, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 2e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/gpt-4.1:batch": { + "cache_read_input_token_cost": 2.5e-07, + "input_cost_per_token": 1e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1047576, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 4e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/gpt-4o-mini:batch": { + "cache_read_input_token_cost": 3.75e-08, + "input_cost_per_token": 7.5e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 3e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "openrouter/openai/gpt-4o:batch": { + "cache_read_input_token_cost": 6.25e-07, + "input_cost_per_token": 1.25e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 5e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "openrouter/openai/gpt-5-image": { + "cache_read_input_token_cost": 1.25e-06, + "input_cost_per_token": 1e-05, + "litellm_provider": "openrouter", + "max_input_tokens": 400000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/gpt-5-image-mini": { + "cache_read_input_token_cost": 2.5e-07, + "input_cost_per_token": 2.5e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 400000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/gpt-5-mini:batch": { + "cache_read_input_token_cost": 1.25e-08, + "input_cost_per_token": 1.25e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 400000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/gpt-5-nano:batch": { + "cache_read_input_token_cost": 2.5e-09, + "input_cost_per_token": 2.5e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 400000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/gpt-5-pro:batch": { + "input_cost_per_token": 7.5e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 400000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 6e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/gpt-5:batch": { + "cache_read_input_token_cost": 6.25e-08, + "input_cost_per_token": 6.25e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 400000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/gpt-5.1:batch": { + "cache_read_input_token_cost": 6.25e-08, + "input_cost_per_token": 6.25e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 400000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/gpt-5.2-pro:batch": { + "input_cost_per_token": 1.05e-05, + "litellm_provider": "openrouter", + "max_input_tokens": 400000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 8.4e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/gpt-5.2:batch": { + "cache_read_input_token_cost": 8.75e-08, + "input_cost_per_token": 8.75e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 400000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 7e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/gpt-5.4-image-2": { + "cache_read_input_token_cost": 2e-06, + "input_cost_per_token": 8e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/gpt-5.4-mini:batch": { + "cache_read_input_token_cost": 3.75e-08, + "input_cost_per_token": 3.75e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 400000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2.25e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/gpt-5.4-nano:batch": { + "cache_read_input_token_cost": 1e-08, + "input_cost_per_token": 1e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 400000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 6.25e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/gpt-5.4-pro:batch": { + "input_cost_per_token": 1.5e-05, + "input_cost_per_token_above_272k_tokens": 3e-05, + "litellm_provider": "openrouter", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 9e-05, + "output_cost_per_token_above_272k_tokens": 0.000135, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/gpt-5.4:batch": { + "cache_read_input_token_cost": 1.25e-07, + "cache_read_input_token_cost_above_272k_tokens": 2.5e-07, + "input_cost_per_token": 1.25e-06, + "input_cost_per_token_above_272k_tokens": 2.5e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 7.5e-06, + "output_cost_per_token_above_272k_tokens": 1.125e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/gpt-5.5-pro:batch": { + "input_cost_per_token": 1.5e-05, + "input_cost_per_token_above_272k_tokens": 3e-05, + "litellm_provider": "openrouter", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 9e-05, + "output_cost_per_token_above_272k_tokens": 0.000135, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/gpt-5.5:batch": { + "cache_read_input_token_cost": 2.5e-07, + "cache_read_input_token_cost_above_272k_tokens": 5e-07, + "input_cost_per_token": 2.5e-06, + "input_cost_per_token_above_272k_tokens": 5e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "output_cost_per_token_above_272k_tokens": 2.25e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/gpt-5.6-luna-pro:batch": { + "cache_read_input_token_cost": 1e-08, + "cache_read_input_token_cost_above_272k_tokens": 2e-08, + "input_cost_per_token": 1e-07, + "input_cost_per_token_above_272k_tokens": 2e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 6e-07, + "output_cost_per_token_above_272k_tokens": 9e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/gpt-5.6-luna:batch": { + "cache_read_input_token_cost": 1e-08, + "cache_read_input_token_cost_above_272k_tokens": 2e-08, + "input_cost_per_token": 1e-07, + "input_cost_per_token_above_272k_tokens": 2e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 6e-07, + "output_cost_per_token_above_272k_tokens": 9e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/gpt-5.6-sol-pro:batch": { + "cache_creation_input_token_cost": 1.25e-06, + "cache_creation_input_token_cost_above_272k_tokens": 2.5e-06, + "cache_read_input_token_cost": 1e-07, + "cache_read_input_token_cost_above_272k_tokens": 2e-07, + "input_cost_per_token": 1e-06, + "input_cost_per_token_above_272k_tokens": 2e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5e-06, + "output_cost_per_token_above_272k_tokens": 7.5e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/gpt-5.6-sol:batch": { + "cache_creation_input_token_cost": 1.25e-06, + "cache_creation_input_token_cost_above_272k_tokens": 2.5e-06, + "cache_read_input_token_cost": 1e-07, + "cache_read_input_token_cost_above_272k_tokens": 2e-07, + "input_cost_per_token": 1e-06, + "input_cost_per_token_above_272k_tokens": 2e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5e-06, + "output_cost_per_token_above_272k_tokens": 7.5e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/gpt-5.6-terra-pro:batch": { + "cache_read_input_token_cost": 1e-07, + "cache_read_input_token_cost_above_272k_tokens": 2e-07, + "input_cost_per_token": 1e-06, + "input_cost_per_token_above_272k_tokens": 2e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 6e-06, + "output_cost_per_token_above_272k_tokens": 9e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/gpt-5.6-terra:batch": { + "cache_read_input_token_cost": 1e-07, + "cache_read_input_token_cost_above_272k_tokens": 2e-07, + "input_cost_per_token": 1e-06, + "input_cost_per_token_above_272k_tokens": 2e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 6e-06, + "output_cost_per_token_above_272k_tokens": 9e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/gpt-6-astra-pro:batch": { + "cache_creation_input_token_cost": 6.25e-06, + "cache_creation_input_token_cost_above_272k_tokens": 1.25e-05, + "cache_read_input_token_cost": 5e-07, + "cache_read_input_token_cost_above_272k_tokens": 1e-06, + "input_cost_per_token": 5e-06, + "input_cost_per_token_above_272k_tokens": 1e-05, + "litellm_provider": "openrouter", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2.5e-05, + "output_cost_per_token_above_272k_tokens": 3.75e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/gpt-6-astra:batch": { + "cache_creation_input_token_cost": 6.25e-06, + "cache_creation_input_token_cost_above_272k_tokens": 1.25e-05, + "cache_read_input_token_cost": 5e-07, + "cache_read_input_token_cost_above_272k_tokens": 1e-06, + "input_cost_per_token": 5e-06, + "input_cost_per_token_above_272k_tokens": 1e-05, + "litellm_provider": "openrouter", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2.5e-05, + "output_cost_per_token_above_272k_tokens": 3.75e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/gpt-oss-120b:batch": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 117964, + "max_tokens": 117964, + "mode": "chat", + "output_cost_per_token": 6e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/openai/o3-mini:batch": { + "cache_read_input_token_cost": 2.75e-07, + "input_cost_per_token": 5.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "chat", + "output_cost_per_token": 2.2e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/openai/o3:batch": { + "cache_read_input_token_cost": 2.5e-07, + "input_cost_per_token": 1e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "chat", + "output_cost_per_token": 4e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/openai/o4-mini:batch": { + "cache_read_input_token_cost": 1.375e-07, + "input_cost_per_token": 5.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "chat", + "output_cost_per_token": 2.2e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/perceptron/perceptron-mk1": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 32768, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.5e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/perplexity/sonar": { + "input_cost_per_token": 1e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 127072, + "max_output_tokens": 114364, + "max_tokens": 114364, + "mode": "chat", + "output_cost_per_token": 1e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": false, + "supports_tool_choice": false, + "supports_vision": true, + "supports_web_search": true + }, + "openrouter/perplexity/sonar-deep-research": { + "input_cost_per_token": 2e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 128000, + "max_output_tokens": 115200, + "max_tokens": 115200, + "mode": "chat", + "output_cost_per_token": 8e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": true + }, + "openrouter/perplexity/sonar-pro": { + "input_cost_per_token": 3e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 200000, + "max_output_tokens": 8000, + "max_tokens": 8000, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": false, + "supports_tool_choice": false, + "supports_vision": true, + "supports_web_search": true + }, + "openrouter/perplexity/sonar-pro-search": { + "input_cost_per_token": 3e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 200000, + "max_output_tokens": 8000, + "max_tokens": 8000, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": true, + "supports_web_search": true + }, + "openrouter/perplexity/sonar-reasoning-pro": { + "input_cost_per_token": 2e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 128000, + "max_output_tokens": 115200, + "max_tokens": 115200, + "mode": "chat", + "output_cost_per_token": 8e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_tool_choice": false, + "supports_vision": true, + "supports_web_search": true + }, + "openrouter/qwen/qwen3.5-9b:batch": { + "input_cost_per_token": 1.7e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 235929, + "max_tokens": 235929, + "mode": "chat", + "output_cost_per_token": 2.5e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/qwen/qwen3.8-2.4t-a95b:batch": { + "cache_read_input_token_cost": 2.5e-07, + "input_cost_per_token": 2e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1010000, + "max_output_tokens": 909000, + "max_tokens": 909000, + "mode": "chat", + "output_cost_per_token": 6e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/qwen/qwen3.8-27b:free": { + "input_cost_per_token": 0.0, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 235929, + "max_tokens": 235929, + "mode": "chat", + "output_cost_per_token": 0.0, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/rekaai/reka-edge": { + "input_cost_per_token": 1e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 16384, + "max_output_tokens": 14745, + "max_tokens": 14745, + "mode": "chat", + "output_cost_per_token": 1e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/rekaai/reka-flash-3": { + "input_cost_per_token": 1e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 65536, + "max_output_tokens": 58982, + "max_tokens": 58982, + "mode": "chat", + "output_cost_per_token": 2e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/relace/relace-apply-3": { + "input_cost_per_token": 8.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 256000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.25e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": false, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/relace/relace-search": { + "input_cost_per_token": 1e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 256000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/sakana/fugu-max": { + "cache_read_input_token_cost": 2.5e-07, + "input_cost_per_token": 2e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 6e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "openrouter/sakana/fugu-ultra": { + "cache_read_input_token_cost": 5e-07, + "cache_read_input_token_cost_above_272k_tokens": 1e-06, + "input_cost_per_token": 5e-06, + "input_cost_per_token_above_272k_tokens": 1e-05, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3e-05, + "output_cost_per_token_above_272k_tokens": 4.5e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "openrouter/sakana/fugu-ultra-v2": { + "cache_read_input_token_cost": 5e-07, + "cache_read_input_token_cost_above_272k_tokens": 1e-06, + "input_cost_per_token": 5e-06, + "input_cost_per_token_above_272k_tokens": 1e-05, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3e-05, + "output_cost_per_token_above_272k_tokens": 4.5e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "openrouter/sakana/sakana-namazu": { + "cache_read_input_token_cost": 1.5e-07, + "input_cost_per_token": 9.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 4e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "openrouter/sao10k/l3-lunaris-8b": { + "input_cost_per_token": 4e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 8192, + "max_output_tokens": 7372, + "max_tokens": 7372, + "mode": "chat", + "output_cost_per_token": 5e-08, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/sao10k/l3.1-euryale-70b": { + "input_cost_per_token": 8.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 8.5e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/sao10k/l3.3-euryale-70b": { + "input_cost_per_token": 6.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 7.5e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/stepfun/step-3.5-flash": { + "input_cost_per_token": 1e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 3e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/stepfun/step-3.7-flash": { + "cache_read_input_token_cost": 4e-08, + "input_cost_per_token": 2e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 230400, + "max_tokens": 230400, + "mode": "chat", + "output_cost_per_token": 1.15e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/tencent/hunyuan-a13b-instruct": { + "input_cost_per_token": 1.4e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 117964, + "max_tokens": 117964, + "mode": "chat", + "output_cost_per_token": 5.7e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/tencent/hy-mt2-1.8b": { + "input_cost_per_token": 4.4e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 8192, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1.77e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": false, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/tencent/hy-mt2-30b-a3b": { + "input_cost_per_token": 7.4e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 8192, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 2.95e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/tencent/hy-mt2-7b": { + "input_cost_per_token": 7.4e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 8192, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 2.95e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/tencent/hy3": { + "cache_read_input_token_cost": 3.3e-08, + "input_cost_per_token": 1.32e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5.28e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/tencent/hy3-preview": { + "cache_read_input_token_cost": 6e-08, + "input_cost_per_token": 1.8e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 235929, + "max_tokens": 235929, + "mode": "chat", + "output_cost_per_token": 6e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/tencent/hy4-preview": { + "cache_read_input_token_cost": 4.2e-08, + "input_cost_per_token": 8.34e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 2.501e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/thedrummer/cydonia-24b-v4.1": { + "cache_read_input_token_cost": 1.5e-07, + "input_cost_per_token": 3e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 117964, + "max_tokens": 117964, + "mode": "chat", + "output_cost_per_token": 5e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/thedrummer/skyfall-36b-v2": { + "cache_read_input_token_cost": 2.5e-07, + "input_cost_per_token": 5.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 32768, + "max_output_tokens": 29491, + "max_tokens": 29491, + "mode": "chat", + "output_cost_per_token": 8e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/thedrummer/unslopnemo-12b": { + "input_cost_per_token": 4e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1024000, + "max_output_tokens": 819200, + "max_tokens": 819200, + "mode": "chat", + "output_cost_per_token": 4e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/thinkingmachines/inkling": { + "cache_read_input_token_cost": 1.7e-07, + "input_cost_per_token": 1e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 471859, + "max_tokens": 471859, + "mode": "chat", + "output_cost_per_token": 4.05e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/thinkingmachines/inkling-small": { + "cache_read_input_token_cost": 1e-07, + "input_cost_per_token": 4.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 1.2e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/thinkingmachines/inkling-small:free": { + "input_cost_per_token": 0.0, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 0.0, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_tool_choice": false, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/thinkingmachines/inkling:batch": { + "cache_read_input_token_cost": 1.7e-07, + "input_cost_per_token": 1e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 524288, + "max_output_tokens": 471859, + "max_tokens": 471859, + "mode": "chat", + "output_cost_per_token": 4.05e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/thinkingmachines/inkling:free": { + "input_cost_per_token": 0.0, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 0.0, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_tool_choice": false, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/unbiased/pareto": { + "cache_read_input_token_cost": 2.5e-07, + "input_cost_per_token": 2.5e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 7.5e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/upstage/solar-pro-3": { + "cache_read_input_token_cost": 1.5e-08, + "input_cost_per_token": 1.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 117964, + "max_tokens": 117964, + "mode": "chat", + "output_cost_per_token": 6e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/upstage/solar-pro4": { + "cache_read_input_token_cost": 1.8e-08, + "input_cost_per_token": 9e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 524288, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 3.6e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/writer/palmyra-x5": { + "input_cost_per_token": 6e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1040000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 6e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": false, + "supports_pdf_input": false, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": false, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/x-ai/grok-4.3:batch": { + "cache_read_input_token_cost": 1.6e-07, + "cache_read_input_token_cost_above_200k_tokens": 3.2e-07, + "input_cost_per_token": 1e-06, + "input_cost_per_token_above_200k_tokens": 2e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 900000, + "max_tokens": 900000, + "mode": "chat", + "output_cost_per_token": 2e-06, + "output_cost_per_token_above_200k_tokens": 4e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/z-ai/glm-5.2:batch": { + "cache_read_input_token_cost": 7e-08, + "input_cost_per_token": 7e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 943718, + "max_tokens": 943718, + "mode": "chat", + "output_cost_per_token": 2.2e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false + }, + "openrouter/z-ai/glm-5.3-flash:batch": { + "cache_read_input_token_cost": 1.5e-08, + "input_cost_per_token": 7.5e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 943718, + "max_tokens": 943718, + "mode": "chat", + "output_cost_per_token": 2.5e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "openrouter/z-ai/glm-5.3:batch": { + "cache_read_input_token_cost": 1.3e-07, + "input_cost_per_token": 7e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 943718, + "max_tokens": 943718, + "mode": "chat", + "output_cost_per_token": 2.2e-06, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false, + "supports_web_search": false } } diff --git a/tests/e2e/coverage_registry/mgmt.yaml b/tests/e2e/coverage_registry/mgmt.yaml index 85fbd0acd91..9890902fa5e 100644 --- a/tests/e2e/coverage_registry/mgmt.yaml +++ b/tests/e2e/coverage_registry/mgmt.yaml @@ -72,6 +72,7 @@ - {id: mgmt.callback.list.happy_path, module: mgmt, tier: P2, surface: api, assertions: [happy_path], source: "callback_management_endpoints.py", rationale: "Callback config (smoke)"} - {id: mgmt.cost_tracking.estimate.happy_path, module: mgmt, tier: P2, surface: api, assertions: [happy_path], source: "cost_tracking_settings.py", rationale: "Cost estimate (smoke)"} - {id: mgmt.router_settings.update.happy_path, module: mgmt, tier: P2, surface: api, assertions: [happy_path], source: "router_settings_endpoints.py", rationale: "Router config (smoke)"} +- {id: mgmt.config.allowed_ip.changed_key_only, module: mgmt, tier: P2, surface: api, assertions: [persists], source: "proxy_setting_endpoints.py:496", rationale: "An allowed-IP change leaves unrelated file settings out of the DB row"} - {id: mgmt.jwt_key_mapping.new.happy_path, module: mgmt, tier: P2, surface: api, assertions: [happy_path], source: "jwt_key_mapping_endpoints.py", rationale: "JWT->key mapping (smoke)"} - {id: mgmt.compliance.gdpr.happy_path, module: mgmt, tier: P2, surface: api, assertions: [happy_path], source: "compliance_endpoints.py", rationale: "GDPR ops (smoke)"} - {id: mgmt.tool_management.list.happy_path, module: mgmt, tier: P2, surface: api, assertions: [happy_path], source: "tool_management_endpoints.py", rationale: "Tool inventory (smoke)"} diff --git a/tests/e2e/gateway/stage_mirror_ci_config.yml b/tests/e2e/gateway/stage_mirror_ci_config.yml index 8c8e64443cb..1b6ae93f461 100644 --- a/tests/e2e/gateway/stage_mirror_ci_config.yml +++ b/tests/e2e/gateway/stage_mirror_ci_config.yml @@ -1,4 +1,5 @@ general_settings: + max_parallel_requests: 100 proxy_batch_write_at: 5 enable_jwt_auth: true litellm_jwtauth: diff --git a/tests/e2e/management/test_config_misc_endpoints_e2e.py b/tests/e2e/management/test_config_misc_endpoints_e2e.py index 099ffa4b3bd..0906ab52fe9 100644 --- a/tests/e2e/management/test_config_misc_endpoints_e2e.py +++ b/tests/e2e/management/test_config_misc_endpoints_e2e.py @@ -21,12 +21,13 @@ from __future__ import annotations import math import time from collections.abc import Callable +from typing import Final import pytest -from pydantic import BaseModel +from pydantic import BaseModel, JsonValue from e2e_config import unique_marker -from e2e_http import NoBody, Success, unwrap, unwrap_status +from e2e_http import NoBody, Success, UnknownApiError, unwrap, unwrap_status from lifecycle import ResourceManager from management_client import ManagementClient from models import KeyGenerateBody, LiteLLMParamsBody, TeamNewBody @@ -198,6 +199,19 @@ class ConfigUpdateResponse(BaseModel): message: str +class AllowedIpBody(BaseModel): + ip: str + + +class ConfigFieldInfoParams(BaseModel): + field_name: str + + +class ConfigFieldInfoResponse(BaseModel): + field_name: str + field_value: JsonValue + + class RouterCurrentValues(BaseModel): num_retries: int | None = None @@ -516,6 +530,45 @@ class TestRouterSettings: ) +class TestConfigPersistence: + @pytest.mark.covers("mgmt.config.allowed_ip.changed_key_only") + def test_add_allowed_ip_does_not_store_unrelated_config_value( + self, client: ManagementClient, resources: ResourceManager + ) -> None: + allowed_ip: Final = "127.0.0.1" + added: Final = unwrap( + client.proxy.transport.post( + "/add/allowed_ip", + headers=client.proxy.transport.master, + json=AllowedIpBody(ip=allowed_ip), + response_type=ConfigUpdateResponse, + ) + ) + resources.defer( + lambda: unwrap( + client.proxy.transport.post( + "/delete/allowed_ip", + headers=client.proxy.transport.master, + json=AllowedIpBody(ip=allowed_ip), + response_type=ConfigUpdateResponse, + ) + ) + ) + assert added.message == f"IP {allowed_ip} address added successfully" + + field_info: Final = client.proxy.transport.get( + "/config/field/info", + headers=client.proxy.transport.master, + params=ConfigFieldInfoParams(field_name="max_parallel_requests"), + response_type=ConfigFieldInfoResponse, + ) + match field_info: + case UnknownApiError(status_code=400, body=body): + assert "not in DB" in body + case _: + pytest.fail(f"expected max_parallel_requests to remain absent from the DB row, got {field_info}") + + class TestMcpServerSubmission: @pytest.mark.covers("mgmt.mcp_server.register.happy_path") def test_register_submits_pending_server(self, client: ManagementClient, resources: ResourceManager) -> None: diff --git a/tests/e2e/ui/tests/budgets/budgets.spec.ts b/tests/e2e/ui/tests/budgets/budgets.spec.ts index 1ad1e488d25..89691c05605 100644 --- a/tests/e2e/ui/tests/budgets/budgets.spec.ts +++ b/tests/e2e/ui/tests/budgets/budgets.spec.ts @@ -4,6 +4,8 @@ import { Page } from "../../fixtures/pages"; import { navigateToPage, dismissFeedbackPopup } from "../../helpers/navigation"; import { masterKey } from "../../helpers/traffic"; +const BUDGET_LIST_PATH = "/management/v1/budgets"; + interface StoredBudget { budget_id: string; max_budget: number | null; @@ -30,7 +32,17 @@ async function createBudgetViaApi(page: PlaywrightPage, budget: Partial { + const searched = page.waitForResponse((response) => { + const url = new URL(response.url()); + return ( + response.request().method() === "GET" && + url.pathname === BUDGET_LIST_PATH && + url.searchParams.get("q") === budgetId + ); + }); await page.getByPlaceholder("Search by budget ID").fill(budgetId); + const response = await searched; + expect(response.ok(), `GET ${BUDGET_LIST_PATH}?q=${budgetId} (${response.status()})`).toBe(true); } test.describe("Budgets", () => { diff --git a/tests/litellm_utils_tests/test_utils.py b/tests/litellm_utils_tests/test_utils.py index b68c2cb3d65..e8b3862756f 100644 --- a/tests/litellm_utils_tests/test_utils.py +++ b/tests/litellm_utils_tests/test_utils.py @@ -22,11 +22,7 @@ from litellm.litellm_core_utils.duration_parser import ( ) from litellm.utils import ( check_valid_key, - create_pretrained_tokenizer, - create_tokenizer, - function_to_dict, get_llm_provider, - get_max_tokens, get_supported_openai_params, get_token_count, get_valid_models, @@ -500,74 +496,6 @@ def test_function_to_dict(): # test_function_to_dict() -@pytest.mark.parametrize( - "model, expected_bool", - [ - ("gpt-3.5-turbo", True), - ("azure/gpt-4-1106-preview", True), - ("groq/gemma-7b-it", True), - ("gemini/gemini-2.5-flash", True), - ], -) -def test_supports_function_calling(model, expected_bool): - try: - assert litellm.supports_function_calling(model=model) == expected_bool - except Exception as e: - pytest.fail(f"Error occurred: {e}") - - -@pytest.mark.parametrize( - "model, expected_bool", - [ - ("gpt-4o-mini-search-preview", True), - ("openai/gpt-4o-mini-search-preview", True), - ("gpt-4o-search-preview", True), - ("openai/gpt-4o-search-preview", True), - ("groq/deepseek-r1-distill-llama-70b", False), - ("groq/llama-3.3-70b-versatile", False), - ("codestral/codestral-latest", False), - ], -) -def test_supports_web_search(model, expected_bool): - try: - assert litellm.supports_web_search(model=model) == expected_bool - except Exception as e: - pytest.fail(f"Error occurred: {e}") - - -@pytest.mark.parametrize( - "model, expected_bool", - [ - ("openai/o3-mini", True), - ("o3-mini", True), - ("xai/grok-3-mini-beta", True), - ("xai/grok-3-mini-fast-beta", True), - ("xai/grok-2", False), - ("gpt-3.5-turbo", False), - ], -) -def test_supports_reasoning(model, expected_bool): - os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True" - litellm.model_cost = litellm.get_model_cost_map(url="") - try: - assert litellm.supports_reasoning(model=model) == expected_bool - except Exception as e: - pytest.fail(f"Error occurred: {e}") - - -def test_get_max_token_unit_test(): - """ - More complete testing in `test_completion_cost.py` - """ - model = "bedrock/anthropic.claude-3-haiku-20240307-v1:0" - - max_tokens = get_max_tokens( - model - ) # Returns a number instead of throwing an Exception - - assert isinstance(max_tokens, int) - - def test_get_supported_openai_params() -> None: # Mapped provider assert isinstance(get_supported_openai_params("gpt-4"), list) @@ -1041,73 +969,6 @@ def test_parse_content_for_reasoning(content, expected_reasoning, expected_conte ) -@pytest.mark.parametrize( - "model, expected_bool", - [ - ("vertex_ai/gemini-2.5-pro", True), - ("gemini/gemini-2.5-pro", True), - ("predibase/llama3-8b-instruct", True), - ("databricks/databricks-meta-llama-3-1-70b-instruct", True), - ("gpt-3.5-turbo", False), - ("groq/llama-3.3-70b-versatile", False), - ], -) -def test_supports_response_schema(model, expected_bool): - """ - Unit tests for 'supports_response_schema' helper function. - - Should be true for gemini-2.5-pro on google ai studio / vertex ai AND predibase models - Should be false otherwise - """ - os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True" - litellm.model_cost = litellm.get_model_cost_map(url="") - - from litellm.utils import supports_response_schema - - response = supports_response_schema(model=model, custom_llm_provider=None) - - assert expected_bool == response - - -@pytest.mark.parametrize( - "model, expected_bool", - [ - ("gpt-3.5-turbo", True), - ("gpt-4", True), - ("command-nightly", False), - ("gemini-2.5-pro", True), - ], -) -def test_supports_function_calling_v2(model, expected_bool): - """ - Unit test for 'supports_function_calling' helper function. - """ - from litellm.utils import supports_function_calling - - response = supports_function_calling(model=model, custom_llm_provider=None) - assert expected_bool == response - - -@pytest.mark.parametrize( - "model, expected_bool", - [ - ("gpt-4o", True), - ("gpt-3.5-turbo", False), - ("claude-sonnet-4-6", True), - ("gemini-2.5-flash", True), - ("command-nightly", False), - ], -) -def test_supports_vision(model, expected_bool): - """ - Unit test for 'supports_vision' helper function. - """ - from litellm.utils import supports_vision - - response = supports_vision(model=model, custom_llm_provider=None) - assert expected_bool == response - - def test_usage_object_null_tokens(): """ Unit test. @@ -1146,7 +1007,6 @@ def test_is_base64_encoded(): clear=True, ) def test_async_http_handler(mock_async_client): - import httpx import ssl timeout = 120 @@ -1221,20 +1081,6 @@ def test_async_http_handler_force_ipv4(mock_async_client): litellm.force_ipv4 = False -@pytest.mark.parametrize( - "model, expected_bool", [("gpt-3.5-turbo", False), ("gpt-4o-audio-preview", True)] -) -def test_supports_audio_input(model, expected_bool): - os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True" - litellm.model_cost = litellm.get_model_cost_map(url="") - - from litellm.utils import supports_audio_input, supports_audio_output - - supports_pc = supports_audio_input(model=model) - - assert supports_pc == expected_bool - - def test_is_base64_encoded_2(): from litellm.utils import is_base64_encoded @@ -1569,23 +1415,6 @@ def test_token_counter_with_image_url_with_detail_high(): assert _tokens == DEFAULT_IMAGE_TOKEN_COUNT + 7 -def test_fireworks_ai_vision_capability_from_cost_map(monkeypatch): - """ - Fireworks deprecated document inlining on 2025-06-30, so vision/PDF support is - no longer hardcoded to True for every Fireworks model. Capabilities are read - from the model cost map: unmapped models no longer advertise vision or PDF - support, while mapped VLMs still do. - """ - monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") - monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url="")) - from litellm.utils import supports_pdf_input, supports_vision - - assert supports_vision("fireworks_ai/llama-3.1-8b-instruct") is False - assert supports_pdf_input("fireworks_ai/llama-3.1-8b-instruct") is False - - assert supports_vision("fireworks_ai/minimax-m3") is True - - def test_logprobs_type(): from litellm.types.utils import Logprobs @@ -1728,21 +1557,12 @@ def test_get_valid_models_default(monkeypatch): Prevent regression for existing usage. """ from litellm.utils import get_valid_models - import litellm monkeypatch.setenv("FIREWORKS_API_KEY", "sk-1234") valid_models = get_valid_models() assert len(valid_models) > 0 -def test_supports_vision_gemini(): - os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True" - litellm.model_cost = litellm.get_model_cost_map(url="") - from litellm.utils import supports_vision - - assert supports_vision("gemini-2.5-pro") is True - - def test_pick_cheapest_chat_model_from_llm_provider(): from litellm.litellm_core_utils.llm_request_utils import ( pick_cheapest_chat_models_from_llm_provider, diff --git a/tests/llm_translation/test_azure_o_series.py b/tests/llm_translation/test_azure_o_series.py index ce7e614cbe2..7a223739844 100644 --- a/tests/llm_translation/test_azure_o_series.py +++ b/tests/llm_translation/test_azure_o_series.py @@ -1,15 +1,12 @@ import json import os -from datetime import datetime -from unittest.mock import AsyncMock, patch, MagicMock +from unittest.mock import patch - -import httpx import pytest import litellm -from litellm import Choices, Message, ModelResponse +from litellm import ModelResponse from base_llm_unit_tests import BaseLLMChatTest, BaseOSeriesModelsTest diff --git a/tests/llm_translation/test_lambda_ai.py b/tests/llm_translation/test_lambda_ai.py index edba459b352..e6f8b13d4ba 100644 --- a/tests/llm_translation/test_lambda_ai.py +++ b/tests/llm_translation/test_lambda_ai.py @@ -102,35 +102,3 @@ async def test_lambda_ai_completion_call(): raise -def test_lambda_ai_model_list_populated(): - """Test that lambda_ai_models list is populated correctly""" - # Ensure we're using local model cost map and repopulate models - os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True" - litellm.model_cost = litellm.get_model_cost_map(url="") - - # Clear and repopulate all model lists after reloading model_cost - litellm.lambda_ai_models = set() - litellm.add_known_models() - - # This should be populated by the add_known_models function - assert ( - len(litellm.lambda_ai_models) > 0 - ), "lambda_ai_models list should not be empty" - - # Check that all models in the list are Lambda AI models - for model in litellm.lambda_ai_models: - assert model.startswith( - "lambda_ai/" - ), f"Model {model} should start with 'lambda_ai/'" - - # Check some expected models are in the list - expected_models = [ - "lambda_ai/llama3.1-8b-instruct", - "lambda_ai/hermes3-405b", - "lambda_ai/deepseek-v3-0324", - ] - - for model in expected_models: - assert ( - model in litellm.lambda_ai_models - ), f"{model} should be in lambda_ai_models list" diff --git a/tests/llm_translation/test_perplexity_reasoning.py b/tests/llm_translation/test_perplexity_reasoning.py index 61fbc9d7824..0fdfdd79321 100644 --- a/tests/llm_translation/test_perplexity_reasoning.py +++ b/tests/llm_translation/test_perplexity_reasoning.py @@ -1,4 +1,3 @@ -import json import os from unittest.mock import patch, MagicMock @@ -136,50 +135,6 @@ class TestPerplexityReasoning: == "This is a test response from the reasoning model." ) - def test_perplexity_reasoning_models_support_reasoning(self): - """ - Test that Perplexity Sonar reasoning models are correctly identified as supporting reasoning - """ - from litellm.utils import supports_reasoning - - # Set up local model cost map - os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True" - litellm.model_cost = litellm.get_model_cost_map(url="") - - reasoning_models = [ - "perplexity/sonar-reasoning", - "perplexity/sonar-reasoning-pro", - ] - - for model in reasoning_models: - assert supports_reasoning(model, None), f"{model} should support reasoning" - - def test_perplexity_non_reasoning_models_dont_support_reasoning(self): - """ - Test that non-reasoning Perplexity models don't support reasoning - """ - from litellm.utils import supports_reasoning - - # Set up local model cost map - os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True" - litellm.model_cost = litellm.get_model_cost_map(url="") - - non_reasoning_models = [ - "perplexity/sonar", - "perplexity/sonar-pro", - "perplexity/llama-3.1-sonar-large-128k-chat", - "perplexity/mistral-7b-instruct", - ] - - for model in non_reasoning_models: - # These models should not support reasoning (should return False or raise exception) - try: - result = supports_reasoning(model, None) - # If it doesn't raise an exception, it should return False - assert result is False, f"{model} should not support reasoning" - except Exception: - # If it raises an exception, that's also acceptable behavior - pass @pytest.mark.parametrize( "model,expected_api_base", diff --git a/tests/local_testing/test_completion_cost.py b/tests/local_testing/test_completion_cost.py index d900dcb6f27..f40818b9bf1 100644 --- a/tests/local_testing/test_completion_cost.py +++ b/tests/local_testing/test_completion_cost.py @@ -6,8 +6,7 @@ import litellm.cost_calculator import asyncio import time from typing import Optional -from unittest.mock import AsyncMock, MagicMock, patch -import base64 +from unittest.mock import MagicMock, patch import pytest import litellm @@ -15,9 +14,7 @@ from litellm import ( TranscriptionResponse, completion_cost, cost_per_token, - get_max_tokens, model_cost, - open_ai_chat_completion_models, ) from litellm.llms.custom_httpx.http_handler import HTTPHandler import json @@ -162,12 +159,6 @@ def test_custom_pricing_as_completion_cost_param(): # test_get_palm_tokens() -def test_zephyr_hf_tokens(): - max_tokens = get_max_tokens("huggingface/HuggingFaceH4/zephyr-7b-beta") - print(max_tokens) - assert max_tokens == 32768 - - # test_zephyr_hf_tokens() @@ -426,10 +417,8 @@ def test_groq_response_cost_tracking(is_streaming): from litellm.utils import ( CallTypes, Choices, - Delta, Message, ModelResponse, - StreamingChoices, Usage, ) @@ -548,12 +537,6 @@ def test_gemini_completion_cost(provider): assert calculated_output_cost == output_cost -def _count_characters(text): - # Remove white spaces and count characters - filtered_text = "".join(char for char in text if not char.isspace()) - return len(filtered_text) - - def test_vertex_ai_completion_cost(): os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True" litellm.model_cost = litellm.get_model_cost_map(url="") @@ -817,10 +800,8 @@ def test_completion_cost_azure_common_deployment_name(): from litellm.utils import ( CallTypes, Choices, - Delta, Message, ModelResponse, - StreamingChoices, Usage, ) @@ -1252,7 +1233,7 @@ def test_cost_openai_prompt_caching(): ], ) def test_completion_cost_azure_ai_rerank(model): - from litellm import RerankResponse, rerank + from litellm import RerankResponse os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True" litellm.model_cost = litellm.get_model_cost_map(url="") @@ -1283,7 +1264,7 @@ def test_completion_cost_azure_ai_rerank(model): def test_together_ai_embedding_completion_cost(): - from litellm.utils import Choices, EmbeddingResponse, Message, ModelResponse, Usage + from litellm.utils import EmbeddingResponse, Usage os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True" litellm.model_cost = litellm.get_model_cost_map(url="") @@ -2222,7 +2203,6 @@ async def test_test_completion_cost_gpt4o_audio_output_from_model(stream): ModelResponse, Usage, ChatCompletionAudioResponse, - PromptTokensDetails, CompletionTokensDetailsWrapper, PromptTokensDetailsWrapper, ) @@ -2464,7 +2444,6 @@ def test_add_known_models(): @pytest.mark.skip(reason="flaky test") def test_bedrock_cost_calc_with_region(): - from litellm import completion from litellm import ModelResponse diff --git a/tests/local_testing/test_get_model_info.py b/tests/local_testing/test_get_model_info.py index 38ccfd91f95..37f4ece611d 100644 --- a/tests/local_testing/test_get_model_info.py +++ b/tests/local_testing/test_get_model_info.py @@ -47,12 +47,6 @@ def test_get_model_info_custom_llm_with_same_name_vllm(monkeypatch): assert model_info["input_cost_per_token"] == 0.0 -def test_get_model_info_gemini_pro(): - info = litellm.get_model_info("gemini-2.0-flash") - print("info", info) - assert info["key"] == "gemini-2.0-flash" - - def test_get_model_info_ollama_chat(): from litellm.llms.ollama.completion.transformation import OllamaConfig @@ -354,27 +348,6 @@ def test_get_model_info_huggingface_models(monkeypatch): ) -@pytest.mark.parametrize( - "model, provider", - [ - ("bedrock/us-east-2/us.anthropic.claude-3-haiku-20240307-v1:0", None), - ( - "bedrock/us-east-2/us.anthropic.claude-3-haiku-20240307-v1:0", - "bedrock", - ), - ], -) -def test_get_model_info_cost_calculator_bedrock_region_cris_stripped(model, provider): - """ - ensure cross region inferencing model is used correctly - Relevant Issue: https://github.com/BerriAI/litellm/issues/8115 - """ - info = get_model_info(model=model, custom_llm_provider=provider) - print("info", info) - assert info["key"] == "us.anthropic.claude-3-haiku-20240307-v1:0" - assert info["litellm_provider"] == "bedrock" - - def test_get_model_info_case_insensitive_lookup(monkeypatch): """ Test that model info lookup is case-insensitive. diff --git a/tests/local_testing/test_prompt_caching.py b/tests/local_testing/test_prompt_caching.py deleted file mode 100644 index f6b3fb89e9e..00000000000 --- a/tests/local_testing/test_prompt_caching.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Asserts that prompt caching information is correctly returned for Anthropic, OpenAI, and Deepseek""" - -import io - - -import litellm -import pytest - - -def _usage_format_tests(usage: litellm.Usage): - """ - OpenAI prompt caching - - prompt_tokens = sum of non-cache hit tokens + cache-hit tokens - - total_tokens = prompt_tokens + completion_tokens - - Example - ``` - "usage": { - "prompt_tokens": 2006, - "completion_tokens": 300, - "total_tokens": 2306, - "prompt_tokens_details": { - "cached_tokens": 1920 - }, - "completion_tokens_details": { - "reasoning_tokens": 0 - } - # ANTHROPIC_ONLY # - "cache_creation_input_tokens": 0 - } - ``` - """ - assert usage.total_tokens == usage.prompt_tokens + usage.completion_tokens - - assert usage.prompt_tokens > usage.prompt_tokens_details.cached_tokens - - -def test_supports_prompt_caching(): - from litellm.utils import supports_prompt_caching - - supports_pc = supports_prompt_caching(model="anthropic/claude-sonnet-4-5-20250929") - - assert supports_pc diff --git a/tests/local_testing/test_register_model.py b/tests/local_testing/test_register_model.py index eddd697974c..5f334a27e35 100644 --- a/tests/local_testing/test_register_model.py +++ b/tests/local_testing/test_register_model.py @@ -2,8 +2,6 @@ # This tests calling batch_completions by running 100 messages together import ast -import sys, os -import traceback from pathlib import Path import pytest @@ -32,16 +30,6 @@ def test_update_model_cost(): # test_update_model_cost() -def test_update_model_cost_map_url(): - try: - litellm.register_model( - model_cost="https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json" - ) - assert litellm.model_cost["gpt-4"]["input_cost_per_token"] == 0.00003 - except Exception as e: - pytest.fail(f"An error occurred: {e}") - - # test_update_model_cost_map_url() diff --git a/tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/test_fixture_models.py b/tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/test_fixture_models.py index 80b830369e6..96751cebe01 100644 --- a/tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/test_fixture_models.py +++ b/tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/test_fixture_models.py @@ -3,7 +3,6 @@ from __future__ import annotations import base64 from collections.abc import Callable from datetime import date -from pathlib import Path from typing import Final, cast from unittest.mock import patch from urllib.parse import parse_qs, urlparse @@ -262,20 +261,6 @@ def _reducto_document() -> ReductoDocumentUrlDocument: ) -def test_fixture_catalogs_match_active_registered_ocr_models() -> None: - registry_path: Final = Path(__file__).resolve().parents[6] / "model_prices_and_context_window.json" - registry: Final = MODEL_REGISTRY.validate_json(registry_path.read_text(encoding="utf-8")) - active_registered: Final = frozenset( - model - for model, raw_metadata in registry.items() - if raw_metadata.get("mode") == "ocr" and raw_metadata.get("litellm_provider") in SUPPORTED_OCR_PROVIDERS - for metadata in (_ModelRegistryEntry.model_validate(raw_metadata),) - if metadata.deprecation_date is None or metadata.deprecation_date > date.today() - ) - - assert ACTIVE_OCR_MODELS == active_registered - - @pytest.mark.parametrize( ("fixture_model", "provider_config", "model"), ( diff --git a/tests/test_litellm/batches/test_batch_utils.py b/tests/test_litellm/batches/test_batch_utils.py index da6475394a3..9a089112c70 100644 --- a/tests/test_litellm/batches/test_batch_utils.py +++ b/tests/test_litellm/batches/test_batch_utils.py @@ -1755,6 +1755,44 @@ def test_bedrock_anthropic_shaped_batch_usage_still_parsed(): assert (usage.prompt_tokens, usage.completion_tokens, usage.total_tokens) == (18, 10, 28) +def test_bedrock_titan_embedding_batch_usage_is_parsed(): + """Titan embedding batch lines carry a top-level inputTextTokenCount and no usage block.""" + body = {"embedding": [0.1, 0.2], "embeddingsByType": {"float": [0.1, 0.2]}, "inputTextTokenCount": 17} + usage = bu._get_batch_job_usage_from_response_body(body, custom_llm_provider="bedrock") + assert (usage.prompt_tokens, usage.completion_tokens, usage.total_tokens) == (17, 0, 17) + + +def test_bedrock_titan_embedding_batch_is_billed(): + """Binary embedding rows carry only embeddingsByType and must bill like float rows.""" + rows = [ + {"recordId": "0", "modelOutput": {"embedding": [0.1], "inputTextTokenCount": 10}}, + {"recordId": "1", "modelOutput": {"embeddingsByType": {"binary": [1, 0]}, "inputTextTokenCount": 7}}, + ] + result = bu._aggregate_batch_cost_usage_models( + entries=rows, + custom_llm_provider="bedrock", + model_name="amazon.titan-embed-text-v2:0", + model_info={"input_cost_per_token_batches": 1e-6, "output_cost_per_token_batches": 0.0}, + ) + assert (result.usage.prompt_tokens, result.usage.completion_tokens, result.usage.total_tokens) == (17, 0, 17) + assert result.cost == pytest.approx(17 * 1e-6) + + +@pytest.mark.parametrize( + "body", + [ + {"embedding": [0.1], "inputTextTokenCount": "17"}, + {"embedding": [0.1], "inputTextTokenCount": True}, + {"embedding": [0.1], "inputTextTokenCount": None}, + {"results": [{"outputText": "hi", "tokenCount": 2}], "inputTextTokenCount": 17}, + ], +) +def test_bedrock_input_text_token_count_outside_embedding_lines_is_not_billed(body): + """Only embedding lines are parsed here; Titan text generation lines are left as they were.""" + usage = bu._get_batch_job_usage_from_response_body(body, custom_llm_provider="bedrock") + assert usage.total_tokens == 0 + + def test_unparsable_bedrock_batch_usage_warns(caplog): """An unrecognized usage shape must be visible, not a silent $0.""" body = {"model": "amazon.titan-text-lite-v1", "usage": {"inputTextTokenCount": 42}} diff --git a/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py b/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py index 6b7780acd20..92b1185e542 100644 --- a/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py +++ b/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py @@ -1,15 +1,12 @@ import copy -import datetime import json import os import subprocess import sys import textwrap -import unittest from typing import List, Optional, Tuple -from unittest.mock import ANY, MagicMock, Mock, patch +from unittest.mock import MagicMock, patch -import httpx import pytest import litellm @@ -19,7 +16,6 @@ from litellm.integrations.anthropic_cache_control_hook import ( ) from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler from litellm.types.llms.openai import AllMessageValues -from litellm.types.utils import StandardCallbackDynamicParams @pytest.fixture(autouse=True) @@ -2984,18 +2980,6 @@ class TestPromptCacheBreakpointCapability: yield litellm.utils._cached_get_model_info_helper.cache_clear() - def test_public_helper_reads_the_model_map(self): - from litellm.utils import supports_prompt_cache_breakpoint - - assert supports_prompt_cache_breakpoint("gpt-5.6") is True - assert supports_prompt_cache_breakpoint("openai/gpt-5.6-sol") is True - assert supports_prompt_cache_breakpoint("gpt-5.6", custom_llm_provider="openai") is True - assert supports_prompt_cache_breakpoint("gpt-4.1") is False - - @pytest.mark.parametrize("model", ["gpt-5.6", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]) - def test_model_map_flags_every_openai_gpt_5_6_entry(self, model): - assert litellm.model_cost[model]["litellm_provider"] == "openai" - assert litellm.model_cost[model]["supports_prompt_cache_breakpoint"] is True def test_listed_model_uses_the_model_map_flag(self, monkeypatch): flagged = {**litellm.model_cost["gpt-4.1"], "supports_prompt_cache_breakpoint": True} @@ -3014,9 +2998,6 @@ class TestPromptCacheBreakpointCapability: ) assert supports_openai_prompt_cache_breakpoint("gpt-5.6") is False - def test_listed_gpt_model_without_the_flag_follows_the_version_rule(self): - assert "supports_prompt_cache_breakpoint" not in litellm.model_cost["gpt-4.1"] - assert supports_openai_prompt_cache_breakpoint("gpt-4.1") is False def test_published_map_without_the_flag_still_injects_on_gpt_5_6(self, monkeypatch): unflagged = {k: v for k, v in litellm.model_cost["gpt-5.6"].items() if k != "supports_prompt_cache_breakpoint"} diff --git a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_guardrail_cost.py b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_guardrail_cost.py index af2f169157e..aa2fc0b9a45 100644 --- a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_guardrail_cost.py +++ b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_guardrail_cost.py @@ -1,4 +1,3 @@ -import os import pytest @@ -121,22 +120,6 @@ def test_billed_guardrail_cost_by_unit_treats_none_in_spend_as_billed(): assert billed_guardrail_cost_by_unit(entry) == {"contentPolicyUnits": 0.15} -def test_shipped_bedrock_guardrail_prices_match_aws_pricing_page(monkeypatch): - monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") - litellm.model_cost = litellm.get_model_cost_map(url="") - assert litellm.model_cost["bedrock/guardrails"]["guardrail_cost_per_unit"] == { - "automatedReasoningPolicyUnits": 0.00017, - "contentPolicyImageUnits": 0.00075, - "contentPolicyUnits": 0.00015, - "contextualGroundingPolicyUnits": 0.0001, - "sensitiveInformationPolicyFreeUnits": 0.0, - "sensitiveInformationPolicyUnits": 0.0001, - "topicPolicyUnits": 0.00015, - "wordPolicyUnits": 0.0, - } - assert "bedrock/guardrails" not in litellm.bedrock_models - - def test_guardrail_information_cost_sums_entries(): entries = [ {"guardrail_name": "a", "guardrail_cost": 0.0003}, diff --git a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py index 5775656301d..776d78a04e0 100644 --- a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py +++ b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py @@ -1575,59 +1575,6 @@ def test_generic_cost_per_token_tiered_pricing_bills_reasoning_at_tier_rate(): litellm.model_cost.pop(model, None) -def test_gpt_5_6_alias_prices_match_sol(local_model_cost_map): - """Regression: the bare gpt-5.6 alias routes to GPT-5.6 Sol, so every cost field on - the two entries has to hold the same value. They drifted once before, when Sol took - its promotional cut and gpt-5.6 was left on the pre-cut rates, overbilling callers - who used the alias.""" - alias = litellm.model_cost["gpt-5.6"] - sol = litellm.model_cost["gpt-5.6-sol"] - - cost_fields = sorted(field for field in sol if "cost" in field) - assert len(cost_fields) == 27 - - for field in cost_fields: - assert alias.get(field) == sol.get(field), field - - -@pytest.mark.parametrize( - "model,expected_none,expected_xhigh,expected_minimal", - [ - # Verified against OpenAI's live API on 2026-04-24: - # gpt-5.5 -> supports: none, low, medium, high, xhigh - # gpt-5.5-pro -> supports: medium, high, xhigh - # Neither supports "minimal"; gpt-5.5-pro additionally does not support "none". - # The JSON must reflect this so LiteLLM rejects unsupported values locally - # (or drops them with drop_params=True) instead of round-tripping to OpenAI - # for a 400. - ("gpt-5.5", True, True, False), - ("gpt-5.5-2026-04-23", True, True, False), - ("gpt-5.5-pro", False, True, False), - ("gpt-5.5-pro-2026-04-23", False, True, False), - ], -) -def test_gpt55_reasoning_effort_flags_match_live_openai_api( - _local_model_cost_map, model, expected_none, expected_xhigh, expected_minimal -): - """Pin reasoning_effort capability flags to OpenAI's actual API contract. - - Observed via `POST /v1/chat/completions` with reasoning_effort=minimal: - ``Unsupported value: 'reasoning_effort' does not support 'minimal' with - this model``. gpt-5.5-pro additionally rejects 'none' and 'low'. - """ - - m = litellm.model_cost[model] - assert m.get("supports_none_reasoning_effort") is expected_none, ( - f"{model}: supports_none_reasoning_effort expected {expected_none}" - ) - assert m.get("supports_xhigh_reasoning_effort") is expected_xhigh, ( - f"{model}: supports_xhigh_reasoning_effort expected {expected_xhigh}" - ) - assert m.get("supports_minimal_reasoning_effort") is expected_minimal, ( - f"{model}: supports_minimal_reasoning_effort expected {expected_minimal}" - ) - - @pytest.mark.parametrize( "base_model,dated_model", [ @@ -1662,29 +1609,6 @@ def test_gpt55_dated_variants_match_base_reasoning_effort_capabilities(_local_mo ) -@pytest.mark.parametrize( - "model,expected_none,expected_minimal,expected_xhigh", - [ - # Mirror live OpenAI API contract (verified via openai/gpt-5.5* on - # 2026-04-24): chat accepts {none, low, medium, high, xhigh} but NOT - # minimal; pro accepts {medium, high, xhigh} only. - # NOTE: openai/gpt-5.5* entries currently set supports_minimal=true on - # main (pre #26456). Once that PR lands, OpenAI + Azure flags align. - ("azure/gpt-5.5", True, False, True), - ("azure/gpt-5.5-pro", False, False, True), - ], -) -def test_azure_gpt55_reasoning_effort_flags_match_live_openai_api( - _local_model_cost_map, model, expected_none, expected_minimal, expected_xhigh -): - """Azure entries pin reasoning_effort flags to OpenAI's actual API contract.""" - - m = litellm.model_cost[model] - assert m.get("supports_none_reasoning_effort") is expected_none - assert m.get("supports_minimal_reasoning_effort") is expected_minimal - assert m.get("supports_xhigh_reasoning_effort") is expected_xhigh - - def test_string_cost_values(): """Test that cost values defined as strings are properly converted to floats.""" from unittest.mock import patch @@ -3413,14 +3337,6 @@ GEMINI_38_FLASH_FIELDS_SHARED_WITH_37_FLASH = ( ) -@pytest.mark.parametrize("prefix", ["", "gemini/", "vertex_ai/"]) -def test_gemini_38_flash_matches_37_flash_promotional_pricing(prefix, _local_model_cost_map): - new_model = litellm.model_cost[f"{prefix}gemini-3.8-flash"] - old_model = litellm.model_cost[f"{prefix}gemini-3.7-flash"] - for field in GEMINI_38_FLASH_FIELDS_SHARED_WITH_37_FLASH: - assert new_model[field] == old_model[field], field - - @pytest.mark.parametrize( ("response_quality", "requested_quality", "expected_cost"), [ diff --git a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_tool_call_cost_tracking.py b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_tool_call_cost_tracking.py index 761eed868b5..7bae2eaa338 100644 --- a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_tool_call_cost_tracking.py +++ b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_tool_call_cost_tracking.py @@ -1,4 +1,3 @@ -from collections.abc import Mapping, Sequence import pytest @@ -527,7 +526,6 @@ def _openai_responses_with_web_search_calls(model, num_calls): ResponseFunctionWebSearch, ) - from litellm.types.llms.openai import ResponsesAPIResponse output = [ ResponseFunctionWebSearch( @@ -585,7 +583,6 @@ def test_web_search_call_count_reads_dict_output_items(local_model_cost_map): counter must read their "type" key like the detection gate does, instead of flooring a multi-search response to a single billable search. """ - from litellm.types.llms.openai import ResponsesAPIResponse from litellm.types.utils import Usage model = "gpt-4o-search-preview" @@ -631,7 +628,6 @@ def test_response_includes_output_type_reads_dict_output_items(): items without an "action" field) stay plain dicts in the output union. The gate must read their "type" key instead of returning False and skipping the web search fee. """ - from litellm.types.llms.openai import ResponsesAPIResponse response = ResponsesAPIResponse.model_validate( { @@ -699,34 +695,3 @@ _BEDROCK_MANTLE_WEB_SEARCH_MODELS = ( _BEDROCK_MANTLE_WEB_SEARCH_RATE = 0.012 -def _responses_with_web_search( - model: str, actions: Sequence[Mapping[str, str]], tool_usage: Mapping[str, object] | None = None -) -> ResponsesAPIResponse: - payload = { - "id": "resp_1", - "created_at": 1756900000, - "model": model.split("/", 1)[-1], - "object": "response", - "status": "completed", - "output": [ - {"type": "web_search_call", "id": f"ws_{i}", "status": "completed", "action": action} - for i, action in enumerate(actions) - ], - } - return ResponsesAPIResponse.model_validate( - payload if tool_usage is None else {**payload, "tool_usage": tool_usage} - ) - - -def _web_search_cost(model: str, response: ResponsesAPIResponse, custom_llm_provider: str) -> float: - from litellm.types.utils import Usage - - return StandardBuiltInToolCostTracking.get_cost_for_built_in_tools( - model=model, - response_object=response, - usage=Usage(prompt_tokens=10, completion_tokens=5, total_tokens=15), - custom_llm_provider=custom_llm_provider, - standard_built_in_tools_params=None, - ) - - diff --git a/tests/test_litellm/litellm_core_utils/prompt_templates/test_bedrock_converse_strict_tools_opus_47_48.py b/tests/test_litellm/litellm_core_utils/prompt_templates/test_bedrock_converse_strict_tools_opus_47_48.py index 370ec4b6f60..83ee3437429 100644 --- a/tests/test_litellm/litellm_core_utils/prompt_templates/test_bedrock_converse_strict_tools_opus_47_48.py +++ b/tests/test_litellm/litellm_core_utils/prompt_templates/test_bedrock_converse_strict_tools_opus_47_48.py @@ -14,7 +14,6 @@ rather than forwarded as a no-op the provider can reject. See BerriAI/litellm#33 import pytest from litellm.litellm_core_utils.prompt_templates.factory import _bedrock_tools_pt -from litellm.llms.bedrock.common_utils import bedrock_converse_supports_strict_tools _STRICT_TOOL = [ { @@ -163,76 +162,3 @@ def test_bedrock_tools_pt_strict_dropped_for_non_anthropic(model_id: str) -> Non assert "strict" not in result[0]["toolSpec"] -def test_bedrock_converse_supports_strict_tools_helper() -> None: - """Direct check for the gate helper used by factory.py.""" - assert ( - bedrock_converse_supports_strict_tools("bedrock/us.anthropic.claude-opus-4-7") - is False - ) - assert ( - bedrock_converse_supports_strict_tools("bedrock/us.anthropic.claude-opus-4-8") - is False - ) - assert ( - bedrock_converse_supports_strict_tools( - "anthropic.claude-sonnet-4-5-20250929-v1:0" - ) - is True - ) - assert ( - bedrock_converse_supports_strict_tools("bedrock/us.anthropic.claude-opus-4-6") - is True - ) - assert bedrock_converse_supports_strict_tools("us.amazon.nova-micro-v1:0") is False - assert bedrock_converse_supports_strict_tools("") is False - # Sonnet 4 also rejects strict on Bedrock Converse - assert ( - bedrock_converse_supports_strict_tools( - "anthropic.claude-sonnet-4-20250514-v1:0" - ) - is False - ) - assert ( - bedrock_converse_supports_strict_tools( - "bedrock/global.anthropic.claude-sonnet-4-20250514-v1:0" - ) - is False - ) - assert bedrock_converse_supports_strict_tools("anthropic.claude-sonnet-5") is False - assert ( - bedrock_converse_supports_strict_tools("bedrock/us.anthropic.claude-sonnet-5") - is False - ) - assert ( - bedrock_converse_supports_strict_tools("bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0") - is True - ) - - -@pytest.mark.parametrize( - "cost_map_key", - [ - "anthropic.claude-opus-4-7", - "us.anthropic.claude-opus-4-7", - "anthropic.claude-opus-4-8", - "us.anthropic.claude-opus-4-8", - "anthropic.claude-sonnet-4-20250514-v1:0", - "global.anthropic.claude-sonnet-4-20250514-v1:0", - "us.anthropic.claude-sonnet-4-20250514-v1:0", - "eu.anthropic.claude-sonnet-4-20250514-v1:0", - "apac.anthropic.claude-sonnet-4-20250514-v1:0", - "anthropic.claude-sonnet-5", - "global.anthropic.claude-sonnet-5", - "us.anthropic.claude-sonnet-5", - "eu.anthropic.claude-sonnet-5", - "au.anthropic.claude-sonnet-5", - "jp.anthropic.claude-sonnet-5", - ], -) -def test_strict_tools_flag_set_in_model_cost_map(cost_map_key: str) -> None: - """The gate is driven by ``bedrock_converse_supports_strict_tools: false`` in - ``model_prices_and_context_window.json``, not hardcoded model patterns.""" - from litellm.litellm_core_utils.get_model_cost_map import GetModelCostMap - - cost_map = GetModelCostMap.load_local_model_cost_map() - assert cost_map[cost_map_key]["bedrock_converse_supports_strict_tools"] is False diff --git a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py index 034062826f6..6bc0e4105f1 100644 --- a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py +++ b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py @@ -1,5 +1,4 @@ import base64 -import json import logging import os import re @@ -10,7 +9,6 @@ import pytest import litellm from litellm.litellm_core_utils.prompt_templates.factory import ( - BAD_MESSAGE_ERROR_STR, BEDROCK_DOCUMENT_PLACEHOLDER_TEXT, BedrockConverseMessagesProcessor, BedrockImageProcessor, @@ -1243,7 +1241,6 @@ def test_bedrock_image_processor_content_type_document_formats(): """ Test that _post_call_image_processing handles various document formats """ - import base64 # Create mock response mock_response = MagicMock() diff --git a/tests/test_litellm/litellm_core_utils/test_fallback_generalizations.py b/tests/test_litellm/litellm_core_utils/test_fallback_generalizations.py index 91e43cba825..25a12bebf9a 100644 --- a/tests/test_litellm/litellm_core_utils/test_fallback_generalizations.py +++ b/tests/test_litellm/litellm_core_utils/test_fallback_generalizations.py @@ -488,13 +488,6 @@ def test_shipped_gemini_chat_baseline_resolves_unmapped_ids(shipped_cost_map, mo assert not info.get("output_cost_per_token") -def test_shipped_gemini_chat_baseline_loses_to_perplexity_exact_entries(shipped_cost_map): - info = litellm.get_model_info("google/gemini-2.5-pro", custom_llm_provider="perplexity") - entry = litellm.model_cost["perplexity/google/gemini-2.5-pro"] - assert info["mode"] == "responses" - assert entry["supports_reasoning"] is False - - def test_shipped_gemini_chat_baseline_skips_non_chat_and_pre_2_5_ids(shipped_cost_map): for model in ( "gemini/gemini-4-flash-image", @@ -809,20 +802,6 @@ def test_shipped_rules_flag_unmapped_wandb_ids_as_reasoning(shipped_cost_map): assert litellm.supports_reasoning(model="zai-org/GLM-6-Turbo", custom_llm_provider="wandb") is True -def test_shipped_wandb_rule_loses_to_mapped_non_reasoning_entries(shipped_cost_map): - """The whole point of a fallback is that it only fills gaps. A wandb model the map - describes as non-reasoning must stay non-reasoning, otherwise the rule silently - re-introduces the blanket supports_reasoning it exists to avoid.""" - for model in ( - "meta-llama/Llama-3.1-8B-Instruct", - "microsoft/Phi-4-mini-instruct", - "moonshotai/Kimi-K2-Instruct", - "Qwen/Qwen3-Coder-480B-A35B-Instruct", - ): - assert f"wandb/{model}" in litellm.model_cost, model - assert litellm.supports_reasoning(model=model, custom_llm_provider="wandb") is False, model - - def test_shipped_wandb_rule_does_not_fill_missing_mapped_entries(shipped_cost_map): assert match_fill_missing_generalizations("wandb/meta-llama/Llama-3.1-8B-Instruct", "wandb") is None @@ -941,47 +920,11 @@ def test_shipped_openai_reasoning_rule_skips_non_reasoning_gpt_ids(shipped_cost_ assert match_capability_generalizations(model) is None, model -def test_shipped_openai_reasoning_rule_loses_to_mapped_entries(shipped_cost_map): - assert "gpt-5-search-api" in litellm.model_cost - assert litellm.supports_reasoning(model="gpt-5-search-api", custom_llm_provider="openai") is False - - -@pytest.mark.parametrize( - "model,provider,expected_supports_reasoning", - [ - ("azure/us/o1-2024-12-17", "azure", True), - ("github_copilot/gpt-5", "github_copilot", None), - ("perplexity/openai/gpt-5.4-mini", "perplexity", None), - ], -) -def test_shipped_openai_reasoning_rule_backfills_only_approved_providers( - shipped_cost_map, model, provider, expected_supports_reasoning -): - assert model in litellm.model_cost - raw_entry = litellm.model_cost[model] - assert "supports_reasoning" not in raw_entry - model_without_provider = model.removeprefix(f"{provider}/") - info = litellm.get_model_info(model=model_without_provider, custom_llm_provider=provider) - assert info.get("supports_reasoning") is expected_supports_reasoning - assert info["input_cost_per_token"] == raw_entry.get("input_cost_per_token", 0) - - def test_shipped_openai_reasoning_rule_matches_only_openai(shipped_cost_map): assert match_fill_missing_generalizations("gpt-5.4", "openai") == {"supports_reasoning": True} assert match_fill_missing_generalizations("gpt-5.4", "openrouter") is None -def test_shipped_openai_reasoning_rule_skips_non_text_modes(shipped_cost_map): - model = "gemini/deep-research-pro-preview-12-2025" - assert model in litellm.model_cost - raw_entry = litellm.model_cost[model] - assert "supports_reasoning" not in raw_entry - assert raw_entry["mode"] == "image_generation" - - info = litellm.get_model_info("deep-research-pro-preview-12-2025", custom_llm_provider="gemini") - assert info.get("supports_reasoning") is None - - def test_shipped_claude_thinking_rules_backfill_only_anthropic(shipped_cost_map): model = "perplexity/anthropic/claude-sonnet-4-6" assert model in litellm.model_cost diff --git a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py index 9124a655840..8ce5357dc94 100644 --- a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py +++ b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py @@ -2378,7 +2378,7 @@ async def test_e2e_generate_cold_storage_object_key_with_custom_logger_s3_path() Test that _generate_cold_storage_object_key uses s3_path from custom logger instance. """ from datetime import datetime, timezone - from unittest.mock import AsyncMock, MagicMock, patch + from unittest.mock import MagicMock, patch from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup @@ -2425,7 +2425,7 @@ async def test_e2e_generate_cold_storage_object_key_with_logger_no_s3_path(): Test that _generate_cold_storage_object_key falls back to empty s3_path when logger has no s3_path. """ from datetime import datetime, timezone - from unittest.mock import AsyncMock, MagicMock, patch + from unittest.mock import MagicMock, patch from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup diff --git a/tests/test_litellm/litellm_core_utils/test_streaming_chunk_builder_utils.py b/tests/test_litellm/litellm_core_utils/test_streaming_chunk_builder_utils.py index fe73bdba9cb..9b921eb2cc7 100644 --- a/tests/test_litellm/litellm_core_utils/test_streaming_chunk_builder_utils.py +++ b/tests/test_litellm/litellm_core_utils/test_streaming_chunk_builder_utils.py @@ -1,4 +1,3 @@ -import json from collections.abc import Mapping, Sequence from typing import Final @@ -400,7 +399,6 @@ def test_streaming_preserves_anthropic_1hr_cache_creation_breakdown(): assert usage.cache_read_input_tokens == 8728 - def test_streaming_keeps_cache_creation_breakdown_from_final_chunk(): """When the final usage chunk itself carries the cache-creation breakdown, aggregation must keep that breakdown instead of re-attaching a stale one diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/test_reasoning_effort_fields.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/test_reasoning_effort_fields.py index 788f1b465d7..1c05f0adcf7 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/test_reasoning_effort_fields.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/test_reasoning_effort_fields.py @@ -9,7 +9,7 @@ Covers: import json import os -from typing import Any, Dict, Optional +from typing import Any, Dict import pytest @@ -42,22 +42,6 @@ class TestGetModelInfoReasoningEffortFields: """get_model_info should expose supports_minimal_reasoning_effort and supports_max_reasoning_effort from the model registry.""" - def test_opus_4_6_has_supports_minimal(self): - info = get_model_info("claude-opus-4-6") - assert "supports_minimal_reasoning_effort" in info - - def test_opus_4_6_has_supports_max(self): - info = get_model_info("claude-opus-4-6") - assert "supports_max_reasoning_effort" in info - - def test_opus_4_7_has_supports_minimal(self): - info = get_model_info("claude-opus-4-7") - assert "supports_minimal_reasoning_effort" in info - - def test_opus_4_7_has_supports_max(self): - info = get_model_info("claude-opus-4-7") - assert "supports_max_reasoning_effort" in info - # --------------------------------------------------------------------------- # Commit 2: JSON registry has correct reasoning effort fields diff --git a/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py b/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py index e1b39c4ba13..133d6e502f4 100644 --- a/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py +++ b/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py @@ -1974,20 +1974,6 @@ class TestClaudeOpus48AdaptiveThinking: assert AnthropicModelInfo._is_adaptive_thinking_model(model, "anthropic") is True - def test_resolver_reads_flag_through_bedrock_invoke_prefix(self, local_model_cost_map): - """The resolver fix: ``bedrock/invoke/...`` resolves to the flagged - Bedrock entry. Pure ``_supports_factory`` without prefix-stripping - returns False here, which is why the data-only fix alone was not enough.""" - from litellm.llms.anthropic.common_utils import AnthropicModelInfo - - assert ( - AnthropicModelInfo._supports_model_capability( - "bedrock/invoke/us.anthropic.claude-opus-4-8", - "supports_adaptive_thinking", - "anthropic", - ) - is True - ) @pytest.mark.parametrize( "model", @@ -2172,15 +2158,6 @@ class TestCapabilityProbeUsesCallerProvider: assert AnthropicModelInfo._is_adaptive_thinking_model(self.BEDROCK_MODEL, "bedrock") is False - def test_native_anthropic_probe_still_reads_anthropic_entry(self, local_model_cost_map, monkeypatch): - import litellm - from litellm.llms.anthropic.common_utils import AnthropicModelInfo - - monkeypatch.setitem(litellm.model_cost[self.BEDROCK_MODEL], "supports_adaptive_thinking", False) - litellm.get_model_info.cache_clear() - - assert AnthropicModelInfo._is_adaptive_thinking_model("claude-opus-4-8", "anthropic") is True - def test_create_anthropic_model_list_response_shape(): from litellm.llms.anthropic.common_utils import ( diff --git a/tests/test_litellm/llms/azure/test_azure_speech_audio_transcription.py b/tests/test_litellm/llms/azure/test_azure_speech_audio_transcription.py index 6ed6be6f34f..b447645bae8 100644 --- a/tests/test_litellm/llms/azure/test_azure_speech_audio_transcription.py +++ b/tests/test_litellm/llms/azure/test_azure_speech_audio_transcription.py @@ -1,6 +1,4 @@ import io -import json -from pathlib import Path from unittest.mock import MagicMock import httpx @@ -228,12 +226,3 @@ def test_azure_speech_transcription_routes_through_provider_config(monkeypatch): assert audio_handler.call_args.kwargs["custom_llm_provider"] == "azure" -def test_azure_speech_stt_has_non_zero_input_pricing(): - pricing_path = Path(__file__).parents[4] / "model_prices_and_context_window.json" - pricing = json.loads(pricing_path.read_text()) - - assert pricing["azure/speech/azure-stt"]["input_cost_per_second"] > 0 - assert ( - pricing["azure/speech/azure-stt"]["audio_transcription_config"] - == "azure_speech" - ) diff --git a/tests/test_litellm/llms/azure_ai/claude/test_azure_anthropic_messages_transformation.py b/tests/test_litellm/llms/azure_ai/claude/test_azure_anthropic_messages_transformation.py index 326edde743d..b78b2d0d842 100644 --- a/tests/test_litellm/llms/azure_ai/claude/test_azure_anthropic_messages_transformation.py +++ b/tests/test_litellm/llms/azure_ai/claude/test_azure_anthropic_messages_transformation.py @@ -317,7 +317,6 @@ class TestProviderConfigManagerAzureAnthropicMessages: assert config is None - def test_messages_thinking_shape_follows_exact_azure_entry_flag(local_model_cost_map, monkeypatch): """The Azure messages config must probe capabilities under ``azure_ai`` so an operator setting ``supports_adaptive_thinking: false`` on the exact diff --git a/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py b/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py index 9b28e42f93b..96c78c1cf75 100644 --- a/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py +++ b/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py @@ -1,15 +1,13 @@ -import asyncio import json import os import httpx import pytest -from fastapi.testclient import TestClient from unittest.mock import MagicMock, patch import litellm -from litellm import ModelResponse, RateLimitError, completion +from litellm import ModelResponse from litellm.llms.bedrock.chat.converse_transformation import AmazonConverseConfig from litellm.types.llms.bedrock import ConverseTokenUsageBlock @@ -222,35 +220,6 @@ def test_bedrock_invoke_nova_cache_read_billed_at_discounted_rate(monkeypatch): assert completion_cost == pytest.approx(3 * model_info["output_cost_per_token"]) -@pytest.mark.parametrize( - "model", - [ - "amazon.nova-micro-v1:0", - "amazon.nova-lite-v1:0", - "amazon.nova-pro-v1:0", - "us.amazon.nova-micro-v1:0", - "us.amazon.nova-lite-v1:0", - "us.amazon.nova-pro-v1:0", - "eu.amazon.nova-micro-v1:0", - "eu.amazon.nova-lite-v1:0", - "eu.amazon.nova-pro-v1:0", - "apac.amazon.nova-micro-v1:0", - "apac.amazon.nova-lite-v1:0", - "apac.amazon.nova-pro-v1:0", - "bedrock/us-gov-west-1/amazon.nova-micro-v1:0", - "bedrock/us-gov-west-1/amazon.nova-lite-v1:0", - "bedrock/us-gov-west-1/amazon.nova-pro-v1:0", - "bedrock/us-gov-east-1/amazon.nova-pro-v1:0", - ], -) -def test_nova_prompt_caching_models_price_cache_reads_below_the_input_rate(model, monkeypatch): - monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") - monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url="")) - entry = litellm.model_cost[model] - assert entry["supports_prompt_caching"] is True - assert 0 < entry["cache_read_input_token_cost"] < entry["input_cost_per_token"] - - def test_transform_usage_with_reasoning_content(): """Test that completion_tokens_details correctly tracks reasoning vs text tokens.""" usage = ConverseTokenUsageBlock( @@ -1377,13 +1346,8 @@ def test_parallel_tool_calls_config_dropped_for_ttl_only_model( def test_transform_response_with_computer_use_tool(): """Test response transformation with computer use tool call.""" - import httpx from litellm.llms.bedrock.chat.converse_transformation import AmazonConverseConfig - from litellm.types.llms.bedrock import ( - ConverseResponseBlock, - ConverseTokenUsageBlock, - ) from litellm.types.utils import ModelResponse # Simulate a Bedrock Converse response with a computer-use tool call @@ -1472,13 +1436,8 @@ def test_transform_response_with_computer_use_tool(): def test_transform_response_with_bash_tool(): """Test response transformation with bash tool call.""" - import httpx from litellm.llms.bedrock.chat.converse_transformation import AmazonConverseConfig - from litellm.types.llms.bedrock import ( - ConverseResponseBlock, - ConverseTokenUsageBlock, - ) from litellm.types.utils import ModelResponse # Simulate a Bedrock Converse response with a bash tool call @@ -4206,79 +4165,6 @@ def test_drop_thinking_param_when_thinking_blocks_missing(): litellm.modify_params = original_modify_params -def test_supports_native_structured_outputs(monkeypatch): - """Test model detection for native structured outputs support. - - Support is driven by the ``supports_native_structured_output`` flag in the - cost JSON (litellm.model_cost), not a hardcoded model set. - """ - old_env = os.environ.get("LITELLM_LOCAL_MODEL_COST_MAP") - old_cost = litellm.model_cost - monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") - litellm.model_cost = litellm.get_model_cost_map(url="") - try: - config = AmazonConverseConfig() - - # Supported models (have supports_native_structured_output=true in cost JSON) - assert config._supports_native_structured_outputs( - "anthropic.claude-sonnet-4-5-20250929-v1:0" - ) - assert config._supports_native_structured_outputs( - "anthropic.claude-haiku-4-5-20251001-v1:0" - ) - assert config._supports_native_structured_outputs( - "anthropic.claude-opus-4-6-v1" - ) - # Regional prefix is stripped by get_bedrock_base_model - assert config._supports_native_structured_outputs( - "eu.anthropic.claude-opus-4-5-20251101-v1:0" - ) - # Claude 4.6 Sonnet - assert config._supports_native_structured_outputs("anthropic.claude-sonnet-4-6") - assert config._supports_native_structured_outputs( - "us.anthropic.claude-sonnet-4-6" - ) - # Non-Anthropic models - assert config._supports_native_structured_outputs( - "qwen.qwen3-235b-a22b-2507-v1:0" - ) - assert config._supports_native_structured_outputs( - "mistral.mistral-large-3-675b-instruct" - ) - assert config._supports_native_structured_outputs("minimax.minimax-m2") - assert config._supports_native_structured_outputs("moonshot.kimi-k2-thinking") - assert config._supports_native_structured_outputs("nvidia.nemotron-nano-3-30b") - # DeepSeek: old substring "deepseek-v3.1" didn't match real ID - assert config._supports_native_structured_outputs("deepseek.v3-v1:0") - assert config._supports_native_structured_outputs("deepseek.v3.2") - assert config._supports_native_structured_outputs("zai.glm-5") - - # Unsupported models -- should fall back to tool-call approach - assert not config._supports_native_structured_outputs( - "anthropic.claude-sonnet-4-20250514-v1:0" - ) - assert not config._supports_native_structured_outputs( - "meta.llama3-3-70b-instruct-v1:0" - ) - assert not config._supports_native_structured_outputs("amazon.nova-pro-v1:0") - # Excluded: broken constrained decoding on Bedrock - assert not config._supports_native_structured_outputs("openai.gpt-oss-120b-1:0") - assert not config._supports_native_structured_outputs( - "mistral.magistral-small-2509" - ) - # Excluded: ignores schema or broken on Bedrock - assert not config._supports_native_structured_outputs("google.gemma-3-27b-it") - assert not config._supports_native_structured_outputs( - "nvidia.nemotron-nano-12b-v2" - ) - finally: - litellm.model_cost = old_cost - if old_env is None: - os.environ.pop("LITELLM_LOCAL_MODEL_COST_MAP", None) - else: - monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", old_env) - - def test_create_output_config_for_response_format(): """Test outputConfig dict creation from JSON schema.""" config = AmazonConverseConfig() @@ -7356,7 +7242,6 @@ def test_update_optional_params_with_thinking_tokens_bool_thinking_does_not_cras assert "maxTokens" not in optional_params - @pytest.mark.parametrize( "model, expected_dropped", [ diff --git a/tests/test_litellm/llms/bedrock/image_edit/test_amazon_nova_canvas_image_edit.py b/tests/test_litellm/llms/bedrock/image_edit/test_amazon_nova_canvas_image_edit.py index 58411a9ae18..122dd5b555a 100644 --- a/tests/test_litellm/llms/bedrock/image_edit/test_amazon_nova_canvas_image_edit.py +++ b/tests/test_litellm/llms/bedrock/image_edit/test_amazon_nova_canvas_image_edit.py @@ -3,7 +3,7 @@ import base64 import io from typing import cast -from unittest.mock import Mock, patch +from unittest.mock import Mock import httpx import pytest @@ -483,55 +483,6 @@ def test_transform_request_unknown_quality_reaches_image_generation_config(): assert body["imageGenerationConfig"]["quality"] == "auto" -def test_is_nova_canvas_image_edit_model_uses_model_cost_flag(monkeypatch): - """Routing uses supports_nova_canvas_image_edit in model_cost, not a hardcoded name substring.""" - fake_id = "amazon.custom-bedrock-image-edit-v99:0" - monkeypatch.setitem( - litellm.model_cost, - fake_id, - { - "litellm_provider": "bedrock", - "mode": "image_generation", - "supports_nova_canvas_image_edit": True, - }, - ) - assert ( - BedrockAmazonNovaCanvasImageEditConfig._is_nova_canvas_image_edit_model(fake_id) - is True - ) - - monkeypatch.setitem( - litellm.model_cost, - "amazon.not-nova-canvas-v1:0", - { - "litellm_provider": "bedrock", - "mode": "image_generation", - }, - ) - assert ( - BedrockAmazonNovaCanvasImageEditConfig._is_nova_canvas_image_edit_model( - "amazon.not-nova-canvas-v1:0" - ) - is False - ) - - # Name-shaped ids do not route without supports_nova_canvas_image_edit (no substring heuristic). - monkeypatch.setitem( - litellm.model_cost, - "amazon.nova-canvas-v2:0", - { - "litellm_provider": "bedrock", - "mode": "image_generation", - }, - ) - assert ( - BedrockAmazonNovaCanvasImageEditConfig._is_nova_canvas_image_edit_model( - "amazon.nova-canvas-v2:0" - ) - is False - ) - - def test_transform_response_to_openai_format(): """Response maps images[] to ImageResponse.data b64_json.""" config = BedrockAmazonNovaCanvasImageEditConfig() diff --git a/tests/test_litellm/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py b/tests/test_litellm/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py index ddf184abed3..e43accdb835 100644 --- a/tests/test_litellm/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py +++ b/tests/test_litellm/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py @@ -32,7 +32,6 @@ from litellm.llms.bedrock.messages.invoke_transformations.anthropic_claude3_tran ) - @pytest.mark.asyncio async def test_bedrock_sse_wrapper_encodes_dict_chunks(): """Verify that `bedrock_sse_wrapper` converts dictionary chunks to properly formatted Server-Sent Events and forwards non-dict chunks unchanged.""" @@ -1913,7 +1912,6 @@ async def test_unified_bedrock_messages_sse_usage_and_cost_claude_sonnet_46(): same logging reconstruction as Anthropic /messages. Ensures token counts and completion_cost match model_prices for us.anthropic.claude-sonnet-4-6. """ - from litellm import completion_cost from litellm.proxy.pass_through_endpoints.llm_provider_handlers.anthropic_passthrough_logging_handler import ( AnthropicPassthroughLoggingHandler, ) @@ -2902,22 +2900,6 @@ def test_bedrock_messages_tool_search_follows_claude_tool_search_rule(local_mode assert cfg._supports_tool_search_on_bedrock(model) is expected -def test_bedrock_messages_tool_search_rule_fills_mapped_entry_without_flag(local_model_cost_map, monkeypatch): - """LIT-5851: a Bedrock entry that is in the map but carries no ``supports_tool_search`` - key, the state Opus 4.8, Opus 5 and Sonnet 5 shipped in, is filled by the - ``claude-tool-search`` rule instead of resolving to ``None`` and losing the beta.""" - import litellm - - model = "us.anthropic.claude-opus-5" - cfg = AmazonAnthropicClaudeMessagesConfig() - - monkeypatch.delitem(litellm.model_cost[model], "supports_tool_search") - litellm.get_model_info.cache_clear() - - assert litellm.get_model_info(model, custom_llm_provider="bedrock")["supports_tool_search"] is True - assert cfg._supports_tool_search_on_bedrock(model) is True - - def test_bedrock_messages_thinking_shape_follows_exact_bedrock_entry_flag( local_model_cost_map, monkeypatch ): diff --git a/tests/test_litellm/llms/bedrock/test_bedrock_common_utils.py b/tests/test_litellm/llms/bedrock/test_bedrock_common_utils.py index a8a21e2cd37..df042ce5902 100644 --- a/tests/test_litellm/llms/bedrock/test_bedrock_common_utils.py +++ b/tests/test_litellm/llms/bedrock/test_bedrock_common_utils.py @@ -2,7 +2,6 @@ import pytest - from litellm.llms.bedrock.common_utils import BedrockModelInfo # --------------------------------------------------------------------------- # diff --git a/tests/test_litellm/llms/bedrock_mantle/test_bedrock_mantle_responses_transformation.py b/tests/test_litellm/llms/bedrock_mantle/test_bedrock_mantle_responses_transformation.py index 6758a333b35..901c005f5a3 100644 --- a/tests/test_litellm/llms/bedrock_mantle/test_bedrock_mantle_responses_transformation.py +++ b/tests/test_litellm/llms/bedrock_mantle/test_bedrock_mantle_responses_transformation.py @@ -484,19 +484,6 @@ class TestBedrockMantleResponsesWebSearch: ) assert body["tools"] == [self._WEB_SEARCH_TOOL] - @pytest.mark.parametrize( - "model", - [ - "bedrock_mantle/openai.gpt-5.6-sol", - "bedrock_mantle/openai.gpt-5.6-terra", - "bedrock_mantle/openai.gpt-5.6-luna", - "bedrock_mantle/openai.gpt-5.5", - "bedrock_mantle/openai.gpt-5.4", - ], - ) - def test_cost_map_advertises_web_search_support(self, model): - assert litellm.supports_web_search(model=model) is True - def _codex_exec_tool(): return { @@ -1175,21 +1162,6 @@ class TestBedrockMantleResponsesRegistry: assert isinstance(cfg, BedrockMantleResponsesAPIConfig) assert cfg.use_openai_path is True - def test_gpt_5_5_price_map_declares_openai_responses_path(self, local_cost_map): - # The gpt-5.x entries must carry the data-driven flag so frontier routing - # does not rely on the name-string fallback alone. - assert ( - litellm.model_cost["bedrock_mantle/openai.gpt-5.5"].get( - "use_openai_responses_path" - ) - is True - ) - assert ( - litellm.model_cost["bedrock_mantle/openai.gpt-5.4"].get( - "use_openai_responses_path" - ) - is True - ) @pytest.mark.parametrize( "model", @@ -1361,51 +1333,6 @@ class TestMantleSupportsResponses: model-name match: per-model, so gpt-oss-120b is supported but the safeguard variant is not despite the shared substring.""" - @pytest.mark.parametrize( - "model,model_cost,expected", - [ - # supported_endpoints lists responses -> supported - ( - "openai.gpt-oss-120b", - { - "bedrock_mantle/openai.gpt-oss-120b": { - "supported_endpoints": ["/v1/chat/completions", "/v1/responses"] - } - }, - True, - ), - # chat-only supported_endpoints -> not supported (the discriminator) - ( - "openai.gpt-oss-safeguard-120b", - { - "bedrock_mantle/openai.gpt-oss-safeguard-120b": { - "supported_endpoints": ["/v1/chat/completions"] - } - }, - False, - ), - # mode=responses (no supported_endpoints) -> supported - ( - "somelab.future-model", - {"bedrock_mantle/somelab.future-model": {"mode": "responses"}}, - True, - ), - # mode=chat, no responses endpoint -> not supported - ( - "google.gemma-3-27b-it", - {"bedrock_mantle/google.gemma-3-27b-it": {"mode": "chat"}}, - False, - ), - # absent from model_cost -> no signal -> not supported - ("somelab.unmapped", {}, False), - (None, {}, False), - ], - ) - def test_supports_responses(self, model, model_cost, expected): - from litellm.llms.bedrock_mantle.common_utils import mantle_supports_responses - - assert mantle_supports_responses(model, model_cost) is expected - class TestBedrockMantlePerModelResponsesURL: """End-to-end: the registry-selected config must build the correct wire URL diff --git a/tests/test_litellm/llms/bedrock_mantle/test_bedrock_mantle_transformation.py b/tests/test_litellm/llms/bedrock_mantle/test_bedrock_mantle_transformation.py index 15570eaec4d..0cc3963358f 100644 --- a/tests/test_litellm/llms/bedrock_mantle/test_bedrock_mantle_transformation.py +++ b/tests/test_litellm/llms/bedrock_mantle/test_bedrock_mantle_transformation.py @@ -46,21 +46,6 @@ class TestBedrockMantleProviderRegistration: def test_provider_in_provider_list(self): assert "bedrock_mantle" in litellm.provider_list - def test_models_loaded(self, monkeypatch): - monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "true") - litellm.add_known_models() - assert len(litellm.bedrock_mantle_models) > 0 - assert "bedrock_mantle/openai.gpt-oss-120b" in litellm.bedrock_mantle_models - assert "bedrock_mantle/openai.gpt-oss-20b" in litellm.bedrock_mantle_models - assert ( - "bedrock_mantle/openai.gpt-oss-safeguard-120b" - in litellm.bedrock_mantle_models - ) - assert ( - "bedrock_mantle/openai.gpt-oss-safeguard-20b" - in litellm.bedrock_mantle_models - ) - class TestBedrockMantleConfig: def test_custom_llm_provider(self): @@ -836,15 +821,6 @@ class TestBedrockMantleProviderResolution: class TestBedrockMantlePricing: """Tests that verify Bedrock Mantle uses correct AWS Bedrock pricing, not OpenAI pricing.""" - def test_safeguard_models_have_larger_output_tokens(self, monkeypatch): - monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "true") - litellm.add_known_models() - info_120b = litellm.get_model_info("bedrock_mantle/openai.gpt-oss-120b") - info_safeguard = litellm.get_model_info( - "bedrock_mantle/openai.gpt-oss-safeguard-120b" - ) - assert info_safeguard["max_output_tokens"] > info_120b["max_output_tokens"] - @pytest.mark.parametrize( "model_id", diff --git a/tests/test_litellm/llms/cohere/ocr/test_cohere_parse_cost.py b/tests/test_litellm/llms/cohere/ocr/test_cohere_parse_cost.py deleted file mode 100644 index 7ee34c6c55a..00000000000 --- a/tests/test_litellm/llms/cohere/ocr/test_cohere_parse_cost.py +++ /dev/null @@ -1,28 +0,0 @@ -from pathlib import Path - -import pytest - -import litellm -from litellm.llms.base_llm.ocr.transformation import OCRPage, OCRResponse, OCRUsageInfo - -REPO_ROOT = Path(__file__).parents[5] -COST_MAPS = [ - REPO_ROOT / "model_prices_and_context_window.json", - REPO_ROOT / "litellm" / "model_prices_and_context_window_backup.json", -] -MODELS = [("cohere/parse-v5.0", "cohere"), ("azure_ai/Cohere-parse-v5", "azure_ai")] - - -def _ocr_response(model: str, pages_processed: int) -> OCRResponse: - return OCRResponse( - pages=[OCRPage(index=i, markdown=f"page {i}") for i in range(pages_processed)], - model=model, - usage_info=OCRUsageInfo(pages_processed=pages_processed), - ) - - -@pytest.mark.parametrize("model, provider", MODELS) -def test_model_info_resolves_ocr_mode_and_price(local_model_cost_map, model: str, provider: str) -> None: - info = litellm.get_model_info(model=model, custom_llm_provider=provider) - - assert info["mode"] == "ocr" diff --git a/tests/test_litellm/llms/crusoe/test_crusoe.py b/tests/test_litellm/llms/crusoe/test_crusoe.py index 34a6d37663b..718d00222aa 100644 --- a/tests/test_litellm/llms/crusoe/test_crusoe.py +++ b/tests/test_litellm/llms/crusoe/test_crusoe.py @@ -105,31 +105,3 @@ def test_crusoe_provider_detection_by_prefix(): assert model == "meta-llama/Llama-3.3-70B-Instruct" -def test_crusoe_model_list_populated(monkeypatch): - """Test Crusoe models are present in model_prices_and_context_window.json""" - import litellm - - original_model_cost = litellm.model_cost - original_env = os.environ.get("LITELLM_LOCAL_MODEL_COST_MAP") - try: - monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") - litellm.model_cost = litellm.get_model_cost_map(url="") - - expected = [ - "crusoe/meta-llama/Llama-3.3-70B-Instruct", - "crusoe/deepseek-ai/DeepSeek-R1-0528", - "crusoe/deepseek-ai/DeepSeek-V3-0324", - "crusoe/Qwen/Qwen3-235B-A22B-Instruct-2507", - "crusoe/moonshotai/Kimi-K2-Thinking", - "crusoe/openai/gpt-oss-120b", - "crusoe/google/gemma-3-12b-it", - ] - for model in expected: - assert model in litellm.model_cost, f"{model} not found in model_cost" - assert litellm.model_cost[model].get("litellm_provider") == "crusoe" - finally: - litellm.model_cost = original_model_cost - if original_env is None: - os.environ.pop("LITELLM_LOCAL_MODEL_COST_MAP", None) - else: - monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", original_env) diff --git a/tests/test_litellm/llms/fireworks_ai/chat/test_fireworks_ai_chat_transformation.py b/tests/test_litellm/llms/fireworks_ai/chat/test_fireworks_ai_chat_transformation.py index db25c4307d2..6815f00267c 100644 --- a/tests/test_litellm/llms/fireworks_ai/chat/test_fireworks_ai_chat_transformation.py +++ b/tests/test_litellm/llms/fireworks_ai/chat/test_fireworks_ai_chat_transformation.py @@ -4,7 +4,6 @@ from unittest.mock import MagicMock, patch import pytest import litellm -from litellm import supports_reasoning, supports_vision from litellm.constants import SESSION_ID_GENERATED_METADATA_KEY from litellm.llms.fireworks_ai.chat.transformation import FireworksAIConfig from litellm.llms.fireworks_ai.common_utils import get_fireworks_session_id @@ -282,40 +281,6 @@ def test_handle_message_content_with_tool_calls(): ) -def test_supports_reasoning_effort(): - """Test that reasoning_effort is only supported for specific Fireworks AI models.""" - supported_models = [ - "fireworks_ai/accounts/fireworks/models/qwen3-8b", - "fireworks_ai/accounts/fireworks/models/qwen3-32b", - "fireworks_ai/accounts/fireworks/models/qwen3-coder-480b-a35b-instruct", - "fireworks_ai/accounts/fireworks/models/deepseek-v3p1", - "fireworks_ai/accounts/fireworks/models/deepseek-v3p2", - "fireworks_ai/accounts/fireworks/models/glm-4p5", - "fireworks_ai/accounts/fireworks/models/glm-4p5-air", - "fireworks_ai/accounts/fireworks/models/glm-4p6", - "fireworks_ai/accounts/fireworks/models/glm-4p7", - "fireworks_ai/accounts/fireworks/models/glm-5p1", - "fireworks_ai/accounts/fireworks/models/gpt-oss-120b", - "fireworks_ai/accounts/fireworks/models/gpt-oss-20b", - "fireworks_ai/glm-5p1", - ] - - unsupported_models = [ - "fireworks_ai/accounts/fireworks/models/llama-v3-70b-instruct", - "fireworks_ai/accounts/fireworks/models/mixtral-8x7b-instruct", - ] - - for model in supported_models: - assert ( - supports_reasoning(model=model, custom_llm_provider="fireworks_ai") is True - ), f"{model} should support reasoning_effort" - - for model in unsupported_models: - assert ( - supports_reasoning(model=model, custom_llm_provider="fireworks_ai") is False - ), f"{model} should not support reasoning_effort" - - def test_get_supported_openai_params_reasoning_effort(): """Test that reasoning_effort is only included in supported params for models that support it.""" config = FireworksAIConfig() @@ -973,18 +938,6 @@ def test_thinking_and_reasoning_effort_conflict_rejected(): ) -def test_llama_vision_supports_vision_from_model_map(): - config = FireworksAIConfig() - - for model in [ - "fireworks_ai/accounts/fireworks/models/llama-v3p2-11b-vision-instruct", - "fireworks_ai/accounts/fireworks/models/minimax-m3", - "fireworks_ai/minimax-m3", - ]: - assert supports_vision(model=model, custom_llm_provider="fireworks_ai") is True - assert config.get_provider_info(model)["supports_vision"] is True - - def test_transform_messages_helper_rejects_file_blocks(): config = FireworksAIConfig() messages = [ diff --git a/tests/test_litellm/llms/inception/test_inception_chat_transformation.py b/tests/test_litellm/llms/inception/test_inception_chat_transformation.py index 1a0340a0a67..1d12be2adee 100644 --- a/tests/test_litellm/llms/inception/test_inception_chat_transformation.py +++ b/tests/test_litellm/llms/inception/test_inception_chat_transformation.py @@ -7,7 +7,6 @@ import os from unittest import mock import httpx -import pytest import litellm from litellm.llms.inception.chat.transformation import InceptionChatConfig @@ -232,18 +231,6 @@ def test_inception_in_provider_lists(): assert "https://api.inceptionlabs.ai/v1" in litellm.openai_compatible_endpoints -def test_inception_model_list_populated(monkeypatch): - monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") - litellm.model_cost = litellm.get_model_cost_map(url="") - litellm.inception_models = set() - litellm.add_known_models() - - assert "inception/mercury-2" in litellm.inception_models - assert "inception/mercury-2.5" in litellm.inception_models - for model in litellm.inception_models: - assert model.startswith("inception/") - - def test_inception_completion_targets_inception_endpoint(): """ End-to-end: a completion routed through the inception provider must hit diff --git a/tests/test_litellm/llms/moonshot/test_moonshot_chat_transformation.py b/tests/test_litellm/llms/moonshot/test_moonshot_chat_transformation.py index d484fa437ae..f94ea5e3db2 100644 --- a/tests/test_litellm/llms/moonshot/test_moonshot_chat_transformation.py +++ b/tests/test_litellm/llms/moonshot/test_moonshot_chat_transformation.py @@ -730,10 +730,6 @@ class TestMoonshotResponseSchemaSupport: def model_cost_map(self): return GetModelCostMap.load_local_model_cost_map() - def test_supports_response_schema_utility_reports_true(self, model_cost_map, monkeypatch): - monkeypatch.setattr(litellm, "model_cost", model_cost_map) - assert litellm.utils.supports_response_schema(model="moonshot/kimi-k2.5") is True - class TestMoonshotReasoningEffort: """Moonshot documents reasoning_effort as a top-level chat completions field for its reasoning diff --git a/tests/test_litellm/llms/oci/embed/test_oci_embedding.py b/tests/test_litellm/llms/oci/embed/test_oci_embedding.py index 46a91520ab0..f8242aa3d2b 100644 --- a/tests/test_litellm/llms/oci/embed/test_oci_embedding.py +++ b/tests/test_litellm/llms/oci/embed/test_oci_embedding.py @@ -1,5 +1,3 @@ -import json -import os from unittest.mock import MagicMock, patch import httpx @@ -308,72 +306,4 @@ class TestOCIEmbeddingConfig: litellm_params={}, ) - def test_model_prices_embedding_models(self): - """test all 8 OCI embedding models exist in model_prices_and_context_window.json with mode=embedding.""" - model_prices_path = os.path.join( - os.path.dirname(__file__), - "..", - "..", - "..", - "..", - "..", - "model_prices_and_context_window.json", - ) - with open(model_prices_path) as f: - model_prices = json.load(f) - expected_embedding_models = [ - "oci/cohere.embed-english-v3.0", - "oci/cohere.embed-english-light-v3.0", - "oci/cohere.embed-multilingual-v3.0", - "oci/cohere.embed-multilingual-light-v3.0", - "oci/cohere.embed-english-image-v3.0", - "oci/cohere.embed-english-light-image-v3.0", - "oci/cohere.embed-multilingual-light-image-v3.0", - "oci/cohere.embed-v4.0", - ] - - for model_key in expected_embedding_models: - assert model_key in model_prices, f"Missing model: {model_key}" - assert ( - model_prices[model_key].get("mode") == "embedding" - ), f"Model {model_key} does not have mode='embedding'" - - def test_model_prices_new_chat_models(self): - """test the 16 new OCI chat models exist in model_prices_and_context_window.json with mode=chat.""" - model_prices_path = os.path.join( - os.path.dirname(__file__), - "..", - "..", - "..", - "..", - "..", - "model_prices_and_context_window.json", - ) - with open(model_prices_path) as f: - model_prices = json.load(f) - - expected_chat_models = [ - "oci/xai.grok-3", - "oci/xai.grok-3-fast", - "oci/xai.grok-3-mini", - "oci/xai.grok-3-mini-fast", - "oci/xai.grok-4", - "oci/xai.grok-4-fast", - "oci/xai.grok-4.1-fast", - "oci/xai.grok-4.20", - "oci/xai.grok-4.20-multi-agent", - "oci/xai.grok-code-fast-1", - "oci/cohere.command-a-03-2025", - "oci/cohere.command-a-reasoning-08-2025", - "oci/cohere.command-a-vision-07-2025", - "oci/cohere.command-a-translate-08-2025", - "oci/google.gemini-2.5-pro", - "oci/google.gemini-2.5-flash", - ] - - for model_key in expected_chat_models: - assert model_key in model_prices, f"Missing model: {model_key}" - assert ( - model_prices[model_key].get("mode") == "chat" - ), f"Model {model_key} does not have mode='chat'" diff --git a/tests/test_litellm/llms/openai/responses/test_openai_responses_transformation.py b/tests/test_litellm/llms/openai/responses/test_openai_responses_transformation.py index 2bc8d74e82c..0ef45501d91 100644 --- a/tests/test_litellm/llms/openai/responses/test_openai_responses_transformation.py +++ b/tests/test_litellm/llms/openai/responses/test_openai_responses_transformation.py @@ -1,9 +1,8 @@ import json from types import SimpleNamespace from typing import Final -from unittest.mock import AsyncMock, MagicMock, Mock, patch +from unittest.mock import MagicMock, Mock, patch -import httpx import pytest @@ -15,7 +14,6 @@ from litellm.types.llms.openai import ( ImageGenerationPartialImageEvent, OutputTextDeltaEvent, ResponseCompletedEvent, - ResponsesAPIRequestParams, ResponsesAPIResponse, ResponsesAPIStreamEvents, ) diff --git a/tests/test_litellm/llms/openai/test_gpt5_transformation.py b/tests/test_litellm/llms/openai/test_gpt5_transformation.py index ba51209e0d5..0adc7fa8d5f 100644 --- a/tests/test_litellm/llms/openai/test_gpt5_transformation.py +++ b/tests/test_litellm/llms/openai/test_gpt5_transformation.py @@ -288,24 +288,6 @@ def test_gpt5_1_gpt5_2_gpt5_4_drop_minimal_reasoning_effort(config: OpenAIConfig # GPT-5.1 temperature handling tests -def test_gpt5_1_model_detection(gpt5_config: OpenAIGPT5Config): - """Test that models supporting reasoning_effort='none' are correctly detected via model map.""" - # gpt-5.1 and gpt-5.2 chat variants support none - assert gpt5_config._supports_reasoning_effort_level("gpt-5.1", "none") - assert gpt5_config._supports_reasoning_effort_level("gpt-5.1-2025-11-13", "none") - assert gpt5_config._supports_reasoning_effort_level("gpt-5.1-chat-latest", "none") - assert gpt5_config._supports_reasoning_effort_level("gpt-5.2", "none") - assert gpt5_config._supports_reasoning_effort_level("gpt-5.2-2025-12-11", "none") - # codex/pro/chat variants do not support none - assert not gpt5_config._supports_reasoning_effort_level("gpt-5.1-codex", "none") - assert not gpt5_config._supports_reasoning_effort_level("gpt-5.1-codex-max", "none") - assert not gpt5_config._supports_reasoning_effort_level( - "gpt-5.2-chat-latest", "none" - ) - assert not gpt5_config._supports_reasoning_effort_level("gpt-5.2-pro", "none") - assert not gpt5_config._supports_reasoning_effort_level("gpt-5", "none") - assert not gpt5_config._supports_reasoning_effort_level("gpt-5-mini", "none") - assert not gpt5_config._supports_reasoning_effort_level("gpt-5-codex", "none") def test_gpt5_1_temperature_with_reasoning_effort_none(config: OpenAIConfig): @@ -491,14 +473,6 @@ def test_gpt5_minimal_dict_accepted_for_supported_model(config: OpenAIConfig): assert params["reasoning_effort"] == "minimal" -def test_gpt5_supports_reasoning_effort_level_minimal(gpt5_config: OpenAIGPT5Config): - """Test that _supports_reasoning_effort_level correctly identifies minimal support.""" - assert not gpt5_config._supports_reasoning_effort_level("gpt-5.4", "minimal") - assert not gpt5_config._supports_reasoning_effort_level("gpt-5.4-pro", "minimal") - assert not gpt5_config._supports_reasoning_effort_level("gpt-5.4-mini", "minimal") - assert not gpt5_config._supports_reasoning_effort_level("gpt-5.4-nano", "minimal") - - def test_gpt5_minimal_explicitly_disabled_check(gpt5_config: OpenAIGPT5Config): """_is_reasoning_effort_level_explicitly_disabled returns True only for explicit False entries. diff --git a/tests/test_litellm/llms/openai_like/responses/test_openai_like_responses.py b/tests/test_litellm/llms/openai_like/responses/test_openai_like_responses.py index 1402a8fa7b5..6cc5ffa2dae 100644 --- a/tests/test_litellm/llms/openai_like/responses/test_openai_like_responses.py +++ b/tests/test_litellm/llms/openai_like/responses/test_openai_like_responses.py @@ -6,7 +6,6 @@ import os import sys from unittest.mock import patch -import pytest sys.path.insert( 0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../../..")) @@ -58,12 +57,6 @@ class TestSimpleProviderConfigSupportedEndpoints: class TestJSONProviderRegistryResponsesAPI: """Test supports_responses_api on JSONProviderRegistry.""" - def test_existing_provider_no_responses(self): - """Existing providers without supported_endpoints don't support responses""" - from litellm.llms.openai_like.json_loader import JSONProviderRegistry - - # publicai has no supported_endpoints in JSON, defaults to [] - assert JSONProviderRegistry.supports_responses_api("publicai") is False def test_nonexistent_provider(self): """Non-existent provider returns False""" @@ -74,31 +67,6 @@ class TestJSONProviderRegistryResponsesAPI: is False ) - def test_provider_with_responses_endpoint(self): - """A provider with /v1/responses in supported_endpoints returns True""" - from litellm.llms.openai_like.json_loader import ( - JSONProviderRegistry, - SimpleProviderConfig, - ) - - # Temporarily inject a test provider - test_config = SimpleProviderConfig( - "test_responses_provider", - { - "base_url": "https://test.example.com", - "api_key_env": "TEST_API_KEY", - "supported_endpoints": ["/v1/chat/completions", "/v1/responses"], - }, - ) - JSONProviderRegistry._providers["test_responses_provider"] = test_config - try: - assert ( - JSONProviderRegistry.supports_responses_api("test_responses_provider") - is True - ) - finally: - del JSONProviderRegistry._providers["test_responses_provider"] - class TestCreateResponsesConfigClass: """Test dynamic responses config class generation.""" diff --git a/tests/test_litellm/llms/openai_like/test_cognition_provider.py b/tests/test_litellm/llms/openai_like/test_cognition_provider.py index 9bbbb3b88f2..81895d7dc42 100644 --- a/tests/test_litellm/llms/openai_like/test_cognition_provider.py +++ b/tests/test_litellm/llms/openai_like/test_cognition_provider.py @@ -112,13 +112,6 @@ class TestCognitionProviderIdentity: class TestCognitionCostTracking: - def test_lightning_is_five_times_the_standard_tier(self): - standard = litellm.get_model_info(model="cognition/swe-1.7") - lightning = litellm.get_model_info(model="cognition/swe-1.7-lightning") - - assert lightning["input_cost_per_token"] == pytest.approx(standard["input_cost_per_token"] * 5) - assert lightning["output_cost_per_token"] == pytest.approx(standard["output_cost_per_token"] * 5) - def test_supported_endpoints_matrix(self): matrix = json.loads((Path(litellm.__file__).parent / "provider_endpoints_support_backup.json").read_text()) @@ -129,4 +122,3 @@ class TestCognitionCostTracking: assert endpoints["embeddings"] is False - diff --git a/tests/test_litellm/llms/openai_like/test_meta_provider.py b/tests/test_litellm/llms/openai_like/test_meta_provider.py index 0a0ba369e71..20f5af2567c 100644 --- a/tests/test_litellm/llms/openai_like/test_meta_provider.py +++ b/tests/test_litellm/llms/openai_like/test_meta_provider.py @@ -24,10 +24,6 @@ class TestMetaProviderConfig: assert meta.api_key_env == "META_API_KEY" assert meta.api_base_env == "META_API_BASE" - def test_meta_supports_responses_api(self): - from litellm.llms.openai_like.json_loader import JSONProviderRegistry - - assert JSONProviderRegistry.supports_responses_api("meta") def test_meta_in_openai_compatible_providers(self): from litellm.constants import openai_compatible_providers @@ -192,4 +188,3 @@ class TestMetaAnthropicMessages: assert headers["anthropic-version"] == "2023-06-01" - diff --git a/tests/test_litellm/llms/openai_like/test_scx_ai_provider.py b/tests/test_litellm/llms/openai_like/test_scx_ai_provider.py index 947d9b73e1a..15cc6a34de9 100644 --- a/tests/test_litellm/llms/openai_like/test_scx_ai_provider.py +++ b/tests/test_litellm/llms/openai_like/test_scx_ai_provider.py @@ -154,26 +154,6 @@ class TestSCXAIModelMetadata: with open(json_path) as f: return json.load(f) - def test_scx_ai_models_registered_with_correct_metadata(self): - model_cost = self._load(("model_prices_and_context_window.json",)) - for model in self.SCX_MODELS: - info = model_cost.get(model) - assert info is not None, f"{model} missing from model_prices_and_context_window.json" - assert info["litellm_provider"] == "scx-ai" - assert info["mode"] == "chat" - assert info["input_cost_per_token"] > 0 - assert info["output_cost_per_token"] > 0 - assert info["supports_function_calling"] is True - assert info["supports_tool_choice"] is True - assert info["supports_reasoning"] is True - assert info["supports_response_schema"] is True - assert info.get("supports_vision", False) is (model in self.VISION_MODELS) - - assert info["supports_prompt_caching"] is True - assert 0 < info["cache_read_input_token_cost"] < info["input_cost_per_token"] - - assert info["max_tokens"] == info["max_output_tokens"] - assert info["max_input_tokens"] >= 1_000_000 def test_scx_ai_models_synced_to_backup(self): model_cost = self._load(("model_prices_and_context_window.json",)) diff --git a/tests/test_litellm/llms/openai_like/test_tensormesh_provider.py b/tests/test_litellm/llms/openai_like/test_tensormesh_provider.py index 66dd18fc8d7..1e2e20d2d37 100644 --- a/tests/test_litellm/llms/openai_like/test_tensormesh_provider.py +++ b/tests/test_litellm/llms/openai_like/test_tensormesh_provider.py @@ -129,15 +129,6 @@ class TestTensormeshCostMap: litellm.model_cost = original_model_cost litellm.get_model_info.cache_clear() - def test_models_registered_with_capabilities(self): - for model in TENSORMESH_MODELS: - info = litellm.get_model_info(model) - assert info["litellm_provider"] == "tensormesh" - assert info["mode"] == "chat" - assert litellm.supports_function_calling(model) is True, model - assert litellm.supports_response_schema(model) is True, model - assert litellm.model_cost[model]["supports_tool_choice"] is True, model - assert litellm.model_cost[model]["supports_prompt_caching"] is True, model def test_reasoning_flag_matches_expected_set(self): reasoning_models = { diff --git a/tests/test_litellm/llms/reducto/test_model_info.py b/tests/test_litellm/llms/reducto/test_model_info.py index de7a3ccba64..499adf0d179 100644 --- a/tests/test_litellm/llms/reducto/test_model_info.py +++ b/tests/test_litellm/llms/reducto/test_model_info.py @@ -1,9 +1,6 @@ -import uuid import litellm -from litellm.utils import _invalidate_model_cost_lowercase_map - def test_reducto_provider_registration(): model, custom_llm_provider, _, _ = litellm.get_llm_provider( @@ -14,31 +11,3 @@ def test_reducto_provider_registration(): assert custom_llm_provider == "reducto" -def test_get_model_info_preserves_ocr_cost_per_credit(): - test_model_name = f"reducto/test-cost-propagation-{uuid.uuid4().hex[:12]}" - previous_model_entry = litellm.model_cost.get(test_model_name) - _invalidate_model_cost_lowercase_map() - - try: - litellm.register_model( - { - test_model_name: { - "litellm_provider": "reducto", - "mode": "ocr", - "ocr_cost_per_credit": 0.003, - } - } - ) - - model_info = litellm.get_model_info( - model=test_model_name, - custom_llm_provider="reducto", - ) - - assert model_info.get("ocr_cost_per_credit") == 0.003 - finally: - if previous_model_entry is None: - litellm.model_cost.pop(test_model_name, None) - else: - litellm.model_cost[test_model_name] = previous_model_entry - _invalidate_model_cost_lowercase_map() diff --git a/tests/test_litellm/llms/tencent/chat/test_tencent_chat_transformation.py b/tests/test_litellm/llms/tencent/chat/test_tencent_chat_transformation.py index 9f510786d50..4d6d252ae6e 100644 --- a/tests/test_litellm/llms/tencent/chat/test_tencent_chat_transformation.py +++ b/tests/test_litellm/llms/tencent/chat/test_tencent_chat_transformation.py @@ -247,23 +247,6 @@ class TestAdaptiveThinkingCoercion: assert config._is_adaptive_thinking_model("tencent/no-such-model") is False -def test_minimax_m3_cost_map_entry_marks_adaptive_thinking(): - """The capability flag driving the coercion must exist in the cost map - (and its backup, which is shipped with the package).""" - import json - from pathlib import Path - - repo_root = Path(__file__).parents[5] - for filename in ("model_prices_and_context_window.json", "litellm/model_prices_and_context_window_backup.json"): - with open(repo_root / filename) as f: - entry = json.load(f).get("tencent/minimax-m3") - - assert entry is not None, f"tencent/minimax-m3 not found in {filename}" - assert entry["litellm_provider"] == "tencent" - assert entry.get("supports_adaptive_thinking") is True - assert entry.get("supports_reasoning") is True - - def test_get_complete_url_default(): config = TencentChatConfig() diff --git a/tests/test_litellm/llms/vertex_ai/test_vertex_ai_common_utils.py b/tests/test_litellm/llms/vertex_ai/test_vertex_ai_common_utils.py index 7d2dfbb962e..04a7ee451c4 100644 --- a/tests/test_litellm/llms/vertex_ai/test_vertex_ai_common_utils.py +++ b/tests/test_litellm/llms/vertex_ai/test_vertex_ai_common_utils.py @@ -389,7 +389,6 @@ def test_build_vertex_schema_array_branch_missing_items_in_anyof(): def test_vertex_ai_complex_response_schema(): - import json from copy import deepcopy from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import ( @@ -1192,7 +1191,7 @@ async def test_vertex_ai_token_counter_routes_partner_models(): Test that VertexAITokenCounter correctly routes partner models (Claude, Mistral, etc.) to the partner models token counter instead of the Gemini token counter. """ - from unittest.mock import AsyncMock, patch + from unittest.mock import patch from litellm.llms.vertex_ai.common_utils import VertexAITokenCounter from litellm.types.utils import TokenCountResponse @@ -1242,7 +1241,6 @@ async def test_vertex_ai_token_counter_uses_count_tokens_location(): from unittest.mock import patch from litellm.llms.vertex_ai.common_utils import VertexAITokenCounter - from litellm.types.utils import TokenCountResponse token_counter = VertexAITokenCounter() @@ -1283,7 +1281,7 @@ async def test_vertex_ai_token_counter_routes_gemini_models(): Test that VertexAITokenCounter correctly routes Gemini models to the Gemini token counter (not partner models). """ - from unittest.mock import AsyncMock, patch + from unittest.mock import patch from litellm.llms.vertex_ai.common_utils import VertexAITokenCounter from litellm.types.utils import TokenCountResponse @@ -1757,17 +1755,3 @@ def test_get_vertex_ai_lyria_model_info_is_none_for_non_lyria_speech_models(mode assert get_vertex_ai_lyria_model_info(model=model) is None -def test_get_vertex_ai_lyria_model_info_falls_back_to_bundled_map(monkeypatch): - import litellm - from litellm.llms.vertex_ai.common_utils import get_vertex_ai_lyria_model_info - - stale_runtime_model_cost = { - key: value for key, value in litellm.model_cost.items() if not key.startswith("vertex_ai/lyria") - } - monkeypatch.setattr(litellm, "model_cost", stale_runtime_model_cost) - - model_info = get_vertex_ai_lyria_model_info(model="lyria-3-pro-preview") - - assert model_info is not None - assert model_info["vertex_ai_audio_api"] == "lyria_interactions" - assert model_info["supported_audio_formats"] == ("mp3", "wav") diff --git a/tests/test_litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/test_vertex_ai_partner_models_anthropic_transformation.py b/tests/test_litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/test_vertex_ai_partner_models_anthropic_transformation.py index a57672cfbfb..37a619d6400 100644 --- a/tests/test_litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/test_vertex_ai_partner_models_anthropic_transformation.py +++ b/tests/test_litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/test_vertex_ai_partner_models_anthropic_transformation.py @@ -34,7 +34,6 @@ def test_get_supported_params_thinking(): def test_vertex_ai_anthropic_web_search_header_in_completion(): """Test that web search tool adds the required beta header for Vertex AI completion requests""" - from unittest.mock import MagicMock, patch from litellm.llms.anthropic.common_utils import AnthropicModelInfo @@ -463,9 +462,6 @@ def test_vertex_ai_partner_models_anthropic_remove_prompt_caching_scope_beta_hea Test that remove_unsupported_beta correctly filters out prompt-caching-scope-2026-01-05 from the anthropic-beta headers. """ - from litellm.llms.vertex_ai.vertex_ai_partner_models.anthropic.experimental_pass_through.transformation import ( - VertexAIPartnerModelsAnthropicMessagesConfig, - ) # This beta header should be removed PROMPT_CACHING_BETA_HEADER = "prompt-caching-scope-2026-01-05" diff --git a/tests/test_litellm/llms/vertex_ai/vertex_ai_partner_models/gemma/test_vertex_ai_gemma_global_endpoint.py b/tests/test_litellm/llms/vertex_ai/vertex_ai_partner_models/gemma/test_vertex_ai_gemma_global_endpoint.py index 957d7475d91..e9b58622a4b 100644 --- a/tests/test_litellm/llms/vertex_ai/vertex_ai_partner_models/gemma/test_vertex_ai_gemma_global_endpoint.py +++ b/tests/test_litellm/llms/vertex_ai/vertex_ai_partner_models/gemma/test_vertex_ai_gemma_global_endpoint.py @@ -180,28 +180,6 @@ class TestCreateVertexURLGemma: # --------------------------------------------------------------------------- -def test_gemma_maas_supports_function_calling(): - """supports_function_calling=true in model_cost must be surfaced by the utility.""" - with patch.dict(litellm.model_cost, _GEMMA_MODEL_COST_ENTRY, clear=False): - assert ( - litellm.utils.supports_function_calling( - model="vertex_ai/google/gemma-4-26b-a4b-it-maas" - ) - is True - ) - - -def test_gemma_maas_supports_vision(): - """supports_vision=true in model_cost must be surfaced by the utility.""" - with patch.dict(litellm.model_cost, _GEMMA_MODEL_COST_ENTRY, clear=False): - assert ( - litellm.utils.supports_vision( - model="vertex_ai/google/gemma-4-26b-a4b-it-maas" - ) - is True - ) - - # --------------------------------------------------------------------------- # Integration tests: verify payloads reach the global OpenAI endpoint # diff --git a/tests/test_litellm/llms/vertex_ai/videos/test_vertex_video_transformation.py b/tests/test_litellm/llms/vertex_ai/videos/test_vertex_video_transformation.py index b6b638c6dbe..5c90d54ae90 100644 --- a/tests/test_litellm/llms/vertex_ai/videos/test_vertex_video_transformation.py +++ b/tests/test_litellm/llms/vertex_ai/videos/test_vertex_video_transformation.py @@ -14,7 +14,6 @@ import pytest import litellm from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider -from litellm.llms.openai.cost_calculation import video_generation_cost from litellm.llms.vertex_ai.videos.transformation import ( VertexAIVideoConfig, _convert_image_to_vertex_format, diff --git a/tests/test_litellm/llms/xai/test_xai_model_registry.py b/tests/test_litellm/llms/xai/test_xai_model_registry.py index a455d1fb233..a596afa963f 100644 --- a/tests/test_litellm/llms/xai/test_xai_model_registry.py +++ b/tests/test_litellm/llms/xai/test_xai_model_registry.py @@ -29,24 +29,6 @@ def cost_map(request: pytest.FixtureRequest) -> dict: return json.loads(path.read_text(encoding="utf-8")) -@pytest.mark.parametrize("model", RESPONSES_ONLY_MODELS) -def test_multi_agent_models_are_responses_only(cost_map: dict, model: str): - entry = cost_map[model] - assert entry["supported_endpoints"] == ["/v1/responses"] - assert entry["mode"] == "responses" - - -def test_surviving_xai_chat_models_still_serve_chat_completions(cost_map: dict): - """Guard against the removal above over-reaching into live models.""" - chat_models = [ - key - for key, value in cost_map.items() - if isinstance(value, dict) and value.get("litellm_provider") == "xai" and value.get("mode") == "chat" - ] - assert "xai/grok-4.3" in chat_models - assert "xai/grok-4.6" in chat_models - - def test_both_cost_maps_agree_on_xai_entries(): prices = json.loads(PRICES_PATH.read_text(encoding="utf-8")) backup = json.loads(BACKUP_PRICES_PATH.read_text(encoding="utf-8")) diff --git a/tests/test_litellm/llms/xai/test_xai_redirected_slug_pricing.py b/tests/test_litellm/llms/xai/test_xai_redirected_slug_pricing.py index bbbcfb1b9dc..83e8925f70b 100644 --- a/tests/test_litellm/llms/xai/test_xai_redirected_slug_pricing.py +++ b/tests/test_litellm/llms/xai/test_xai_redirected_slug_pricing.py @@ -85,11 +85,6 @@ def test_code_slug_bills_at_grok_build_rate(cost_map: dict, slug: str): assert entry[field] == target[field], field -def test_a_live_xai_model_is_untouched(cost_map: dict): - """Guard against the repricing leaking onto models xAI still serves directly.""" - assert cost_map["xai/grok-4.6"]["input_cost_per_token"] != cost_map[REDIRECT_TARGET]["input_cost_per_token"] - - @pytest.mark.parametrize("slug", REDIRECTED_SLUGS) def test_redirected_slug_carries_the_target_tier_rates(cost_map: dict, slug: str): """The request executes as grok-4.3, so it is tiered at grok-4.3's 200k boundary.""" diff --git a/tests/test_litellm/proxy/auth/test_model_checks.py b/tests/test_litellm/proxy/auth/test_model_checks.py index 36bfc4c5dd3..3c6733cb86d 100644 --- a/tests/test_litellm/proxy/auth/test_model_checks.py +++ b/tests/test_litellm/proxy/auth/test_model_checks.py @@ -1,10 +1,7 @@ -from unittest.mock import AsyncMock, patch +from unittest.mock import patch import pytest -from litellm.proxy._types import LiteLLM_TeamTable, LiteLLM_UserTable, Member -from litellm.proxy.auth.handle_jwt import JWTAuthManager - def test_get_team_models_for_all_models_and_team_only_models(): from litellm.proxy.auth.model_checks import get_team_models @@ -858,23 +855,6 @@ def test_add_known_models_refreshes_models_by_provider_for_wildcard_expansion(): assert fake_model not in litellm.models_by_provider["vertex_ai"] -def test_azure_ai_wildcard_lists_the_foundry_gpt_6_astra_entry(monkeypatch): - import litellm - from litellm.proxy.auth.model_checks import get_known_models_from_wildcard - - monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") - foundry_key = "azure_ai/gpt-6-astra" - local_entry = litellm.get_model_cost_map(url="")[foundry_key] - registered_before = foundry_key in litellm.azure_ai_models - try: - litellm.add_known_models(model_cost_map={foundry_key: local_entry}) - assert foundry_key in get_known_models_from_wildcard("azure_ai/*") - finally: - if not registered_before: - litellm.azure_ai_models.discard(foundry_key) - litellm.add_known_models(model_cost_map={}) - - def test_get_complete_model_list_drops_no_default_models_sentinel(): from litellm.proxy.auth.model_checks import get_complete_model_list diff --git a/tests/test_litellm/proxy/hooks/test_prompt_injection_detection.py b/tests/test_litellm/proxy/hooks/test_prompt_injection_detection.py index 5701d9a728a..bbd35404136 100644 --- a/tests/test_litellm/proxy/hooks/test_prompt_injection_detection.py +++ b/tests/test_litellm/proxy/hooks/test_prompt_injection_detection.py @@ -1,12 +1,21 @@ +import asyncio +import importlib +import time +from collections.abc import AsyncIterator +from concurrent.futures import ThreadPoolExecutor + import pytest from fastapi import HTTPException +import litellm from litellm.caching.caching import DualCache -from litellm.proxy._types import UserAPIKeyAuth +from litellm.proxy._types import LiteLLMPromptInjectionParams, UserAPIKeyAuth from litellm.proxy.hooks.prompt_injection_detection import ( _OPTIONAL_PromptInjectionDetection, ) +LONG_SAFE_PROMPT = "Summarize the quarterly revenue report for the finance team. " * 3 + @pytest.mark.asyncio async def test_acompletion_call_type_rejects_prompt_injection(): @@ -57,3 +66,76 @@ async def test_acompletion_call_type_allows_safe_prompt(): ) assert result == data + + +@pytest.mark.asyncio +async def test_heuristics_check_keeps_event_loop_responsive(): + detector = _OPTIONAL_PromptInjectionDetection( + prompt_injection_params=LiteLLMPromptInjectionParams(heuristics_check=True) + ) + data = {"model": "test-model", "messages": [{"role": "user", "content": LONG_SAFE_PROMPT}]} + + async def ticks_until_done(task: asyncio.Task[dict]) -> AsyncIterator[float]: + while not task.done(): + await asyncio.sleep(0.01) + yield time.perf_counter() + + scan = asyncio.create_task( + detector.async_pre_call_hook( + user_api_key_dict=UserAPIKeyAuth(api_key="sk-test"), + cache=DualCache(), + data=data, + call_type="acompletion", + ) + ) + started = time.perf_counter() + ticks_during_scan = tuple([tick async for tick in ticks_until_done(scan)]) + finished = time.perf_counter() + result = await scan + + assert result == data + assert len(ticks_during_scan) >= int((finished - started) / 0.05) + + +@pytest.mark.asyncio +async def test_heuristics_check_does_not_occupy_default_executor(): + detector = _OPTIONAL_PromptInjectionDetection( + prompt_injection_params=LiteLLMPromptInjectionParams(heuristics_check=True) + ) + data = {"model": "test-model", "messages": [{"role": "user", "content": LONG_SAFE_PROMPT}]} + loop = asyncio.get_running_loop() + single_worker_default_executor = ThreadPoolExecutor(max_workers=1) + loop.set_default_executor(single_worker_default_executor) + + scan = asyncio.create_task( + detector.async_pre_call_hook( + user_api_key_dict=UserAPIKeyAuth(api_key="sk-test"), + cache=DualCache(), + data=data, + call_type="acompletion", + ) + ) + await asyncio.sleep(0.05) + started = time.perf_counter() + await loop.run_in_executor(None, time.sleep, 0) + unrelated_work_wait = time.perf_counter() - started + result = await scan + scan_wall = time.perf_counter() - started + single_worker_default_executor.shutdown(wait=False) + + assert result == data + assert unrelated_work_wait < scan_wall / 4 + + +@pytest.mark.parametrize( + ("configured", "expected"), + [("3", 3), ("not-an-int", 1), ("0", 1), ("-2", 1)], +) +def test_heuristics_thread_count_config_is_honoured(monkeypatch: pytest.MonkeyPatch, configured: str, expected: int): + monkeypatch.setenv("PROMPT_INJECTION_HEURISTICS_MAX_THREADS", configured) + try: + assert importlib.reload(litellm.constants).PROMPT_INJECTION_HEURISTICS_MAX_THREADS == expected + finally: + monkeypatch.delenv("PROMPT_INJECTION_HEURISTICS_MAX_THREADS") + importlib.reload(litellm.constants) + diff --git a/tests/test_litellm/proxy/proxy_server/test_proxy_config.py b/tests/test_litellm/proxy/proxy_server/test_proxy_config.py index c3660b5c880..48eeb39fecf 100644 --- a/tests/test_litellm/proxy/proxy_server/test_proxy_config.py +++ b/tests/test_litellm/proxy/proxy_server/test_proxy_config.py @@ -13,8 +13,11 @@ import json import logging import os import re +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import datetime from types import SimpleNamespace -from typing import Any, Dict +from typing import Any, Dict, Final from unittest.mock import AsyncMock, MagicMock import pytest @@ -35,7 +38,7 @@ from litellm.proxy.proxy_server import ( ) from .conftest import normalize -from pydantic import ValidationError +from pydantic import JsonValue, TypeAdapter, ValidationError # --------------------------------------------------------------------------- # _is_remote_module_url @@ -853,6 +856,314 @@ async def test_ProxyConfig__process_includes_terminates_on_a_cycle(tmp_path): # --------------------------------------------------------------------------- +_CONFIG_VALUE: Final = TypeAdapter(dict[str, JsonValue]) + + +@dataclass(frozen=True, slots=True) +class _ConfigRow: + param_value: dict[str, JsonValue] | str + + +class _ConfigTable: + def __init__(self, rows: Mapping[str, Mapping[str, JsonValue] | str]) -> None: + self.rows = { + param_name: value if isinstance(value, str) else _CONFIG_VALUE.validate_python(value) + for param_name, value in rows.items() + } + self.upserted_param_names: list[str] = [] + self._section_lock = asyncio.Lock() + + async def find_first(self, *, where: Mapping[str, str]) -> _ConfigRow | None: + value: Final = self.rows.get(where["param_name"]) + await asyncio.sleep(0) + return _ConfigRow(param_value=value) if value is not None else None + + async def upsert( + self, *, where: Mapping[str, str], data: Mapping[str, Mapping[str, str]] + ) -> _ConfigRow: + param_name: Final = where["param_name"] + value: Final = _CONFIG_VALUE.validate_json(data["update"]["param_value"]) + self.rows[param_name] = value + self.upserted_param_names.append(param_name) + return _ConfigRow(param_value=value) + + +class _ConfigTransaction: + def __init__(self, table: _ConfigTable) -> None: + self.litellm_config: Final = table + self._section_lock: Final = table._section_lock + self._locked = False + + async def __aenter__(self) -> _ConfigTransaction: + return self + + async def __aexit__(self, *_: object) -> None: + if self._locked: + self._section_lock.release() + + async def query_raw(self, _: str, __: str) -> None: + await self._section_lock.acquire() + self._locked = True + + +@dataclass(frozen=True, slots=True) +class _ConfigDb: + litellm_config: _ConfigTable + + def tx(self) -> _ConfigTransaction: + return _ConfigTransaction(self.litellm_config) + + +@dataclass(frozen=True, slots=True) +class _ConfigPrisma: + db: _ConfigDb + + def tx(self) -> _ConfigTransaction: + return self.db.tx() + + async def insert_data(self, *, data: Mapping[str, object], table_name: str) -> None: + if table_name != "config": + raise AssertionError(f"Expected config write, got {table_name}") + for param_name, value in data.items(): + self.db.litellm_config.rows[param_name] = _CONFIG_VALUE.validate_python(value) + self.db.litellm_config.upserted_param_names.append(param_name) + + +def _db_backed_proxy_config(monkeypatch, rows: Mapping[str, Mapping[str, JsonValue]]) -> tuple[ProxyConfig, _ConfigTable]: + table: Final = _ConfigTable(rows) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", _ConfigPrisma(db=_ConfigDb(litellm_config=table))) + monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True) + monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {"store_model_in_db": True}) + monkeypatch.setattr("litellm.proxy.proxy_server.invalidate_config_param", AsyncMock()) + return ProxyConfig(), table + + +@pytest.mark.asyncio +async def test_ProxyConfig_save_config_merges_changed_keys_without_copying_file_settings(monkeypatch): + proxy_config, table = _db_backed_proxy_config(monkeypatch, {"general_settings": {"db_only": "stored"}}) + baseline: Final = { + "model_list": [], + "general_settings": {"max_parallel_requests": 5, "file_only": "yaml", "allowed_ips": []}, + "router_settings": {"num_retries": 1}, + "litellm_settings": {"drop_params": True}, + } + proxy_config.update_config_state(config=baseline) + changed: Final = { + **baseline, + "general_settings": {**baseline["general_settings"], "allowed_ips": ["127.0.0.1"]}, + } + + await proxy_config.save_config(changed) + + assert table.rows == {"general_settings": {"db_only": "stored", "allowed_ips": ["127.0.0.1"]}} + assert table.upserted_param_names == ["general_settings"] + + +@pytest.mark.asyncio +async def test_ProxyConfig_save_config_skips_unchanged_config(monkeypatch): + proxy_config, table = _db_backed_proxy_config(monkeypatch, {"general_settings": {"db_only": "stored"}}) + baseline: Final = { + "model_list": [], + "general_settings": {"max_parallel_requests": 5}, + "router_settings": {"num_retries": 1}, + "litellm_settings": {"drop_params": True}, + } + proxy_config.update_config_state(config=baseline) + + await proxy_config.save_config(baseline) + + assert table.rows == {"general_settings": {"db_only": "stored"}} + assert table.upserted_param_names == [] + + +@pytest.mark.asyncio +async def test_ProxyConfig_save_config_skips_unchanged_unmanaged_values(monkeypatch): + proxy_config, table = _db_backed_proxy_config(monkeypatch, {}) + baseline: Final = {"general_settings": {}, "guardrails": {"enabled": True}} + proxy_config.update_config_state(config=baseline) + + await proxy_config.save_config(baseline) + + assert table.rows == {} + assert table.upserted_param_names == [] + + +@pytest.mark.asyncio +async def test_ProxyConfig_save_config_leaves_omitted_sections_unchanged(monkeypatch): + proxy_config, table = _db_backed_proxy_config( + monkeypatch, + {"general_settings": {"allowed_ips": ["10.0.0.1"], "db_only": "stored"}}, + ) + proxy_config.update_config_state( + config={"general_settings": {"allowed_ips": ["10.0.0.1"]}, "router_settings": {"num_retries": 1}} + ) + + await proxy_config.save_config({"router_settings": {"num_retries": 2}}) + + assert table.rows == { + "general_settings": {"allowed_ips": ["10.0.0.1"], "db_only": "stored"}, + "router_settings": {"num_retries": 2}, + } + assert table.upserted_param_names == ["router_settings"] + + +@pytest.mark.asyncio +async def test_ProxyConfig_save_config_decodes_a_serialized_config_row(monkeypatch): + proxy_config, table = _db_backed_proxy_config(monkeypatch, {"general_settings": '{"db_only":"stored"}'}) + proxy_config.update_config_state(config={"general_settings": {"allowed_ips": []}}) + + await proxy_config.save_config({"general_settings": {"allowed_ips": ["127.0.0.1"]}}) + + assert table.rows == {"general_settings": {"db_only": "stored", "allowed_ips": ["127.0.0.1"]}} + assert table.upserted_param_names == ["general_settings"] + + +@pytest.mark.asyncio +async def test_ProxyConfig_save_config_serializes_concurrent_changes_to_one_section(monkeypatch): + first, table = _db_backed_proxy_config(monkeypatch, {"general_settings": {"a": 0, "b": 0}}) + second: Final = ProxyConfig() + baseline: Final = {"general_settings": {"a": 0, "b": 0}} + first.update_config_state(config=baseline) + second.update_config_state(config=baseline) + + await asyncio.gather( + first.save_config({"general_settings": {"a": 1, "b": 0}}), + second.save_config({"general_settings": {"a": 0, "b": 1}}), + ) + + assert table.rows == {"general_settings": {"a": 1, "b": 1}} + + +@pytest.mark.asyncio +async def test_ProxyConfig_save_config_updates_the_baseline_after_a_save(monkeypatch): + proxy_config, table = _db_backed_proxy_config(monkeypatch, {}) + proxy_config.update_config_state(config={"general_settings": {}}) + + await proxy_config.save_config({"general_settings": {"removed_key": True}}) + await proxy_config.save_config({"general_settings": {}}) + + assert table.rows == {"general_settings": {}} + + +@pytest.mark.asyncio +async def test_ProxyConfig_save_config_keeps_omitted_sections_in_its_next_baseline(monkeypatch): + proxy_config, table = _db_backed_proxy_config(monkeypatch, {"general_settings": {"allowed_ips": ["10.0.0.1"]}}) + proxy_config.update_config_state( + config={"general_settings": {"allowed_ips": ["10.0.0.1"]}, "router_settings": {"num_retries": 1}} + ) + + await proxy_config.save_config({"router_settings": {"num_retries": 2}}) + await proxy_config.save_config({"general_settings": {}}) + + assert table.rows == {"general_settings": {}, "router_settings": {"num_retries": 2}} + + +@pytest.mark.asyncio +async def test_ProxyConfig_save_config_uses_the_baseline_from_the_loaded_config(tmp_path, monkeypatch): + config_file: Final = tmp_path / "config.yaml" + config_file.write_text("general_settings:\n yaml_only: true\n") + proxy_config: Final = ProxyConfig() + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None) + monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", False) + first: Final = await proxy_config.get_config(config_file_path=str(config_file)) + second: Final = await proxy_config.get_config(config_file_path=str(config_file)) + table: Final = _ConfigTable({}) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", _ConfigPrisma(db=_ConfigDb(litellm_config=table))) + monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True) + monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {"store_model_in_db": True}) + monkeypatch.setattr("litellm.proxy.proxy_server.invalidate_config_param", AsyncMock()) + first["general_settings"]["first"] = True + second["general_settings"]["second"] = True + + await proxy_config.save_config(second) + await proxy_config.save_config(first) + + assert table.rows == {"general_settings": {"second": True, "first": True}} + + +@pytest.mark.asyncio +async def test_ProxyConfig_save_config_accepts_non_json_model_metadata(monkeypatch): + proxy_config, table = _db_backed_proxy_config(monkeypatch, {}) + proxy_config.update_config_state(config={"general_settings": {"allowed_ips": []}}) + config: Final = { + "model_list": [{"model_name": "date-model", "model_info": {"created_at": datetime(2026, 1, 1)}}], + "general_settings": {"allowed_ips": ["127.0.0.1"]}, + } + + await proxy_config.save_config(config) + + assert table.rows == {"general_settings": {"allowed_ips": ["127.0.0.1"]}} + + +@pytest.mark.asyncio +async def test_ProxyConfig_save_config_writes_only_changed_router_settings(monkeypatch): + proxy_config, table = _db_backed_proxy_config(monkeypatch, {"router_settings": {"db_only": "stored"}}) + baseline: Final = { + "model_list": [], + "general_settings": {"max_parallel_requests": 5}, + "router_settings": {"num_retries": 1}, + "litellm_settings": {"drop_params": True}, + } + proxy_config.update_config_state(config=baseline) + changed: Final = {**baseline, "router_settings": {"num_retries": 2}} + + await proxy_config.save_config(changed) + + assert table.rows == {"router_settings": {"db_only": "stored", "num_retries": 2}} + assert table.upserted_param_names == ["router_settings"] + + +@pytest.mark.asyncio +async def test_ProxyConfig_save_config_removes_a_key_only_when_the_db_has_it(monkeypatch): + proxy_config, table = _db_backed_proxy_config( + monkeypatch, {"general_settings": {"removed_key": "db", "db_only": "stored"}} + ) + baseline: Final = {"general_settings": {"removed_key": "yaml", "file_only": "yaml"}} + proxy_config.update_config_state(config=baseline) + changed: Final = {"general_settings": {"file_only": "yaml"}} + + await proxy_config.save_config(changed) + + assert table.rows == {"general_settings": {"db_only": "stored"}} + assert table.upserted_param_names == ["general_settings"] + + +@pytest.mark.asyncio +async def test_ProxyConfig_save_config_keeps_an_unstored_removed_key_as_a_noop(monkeypatch): + proxy_config, table = _db_backed_proxy_config(monkeypatch, {"general_settings": {"db_only": "stored"}}) + baseline: Final = {"general_settings": {"file_only": "yaml"}} + proxy_config.update_config_state(config=baseline) + + await proxy_config.save_config({"general_settings": {}}) + + assert table.rows == {"general_settings": {"db_only": "stored"}} + assert table.upserted_param_names == [] + + +@pytest.mark.asyncio +async def test_ProxyConfig_get_config_keeps_state_separate_from_returned_config(tmp_path, monkeypatch): + config_file: Final = tmp_path / "config.yaml" + config_file.write_text("general_settings:\n max_parallel_requests: 5\n") + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None) + monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", False) + monkeypatch.setattr("litellm.proxy.proxy_server.user_config_file_path", str(config_file)) + + proxy_config: Final = ProxyConfig() + loaded: Final = await proxy_config.get_config(config_file_path=str(config_file)) + loaded["general_settings"]["max_parallel_requests"] = 6 + + assert proxy_config.get_config_state()["general_settings"]["max_parallel_requests"] == 5 + + +def test_ProxyConfig_update_config_state_keeps_a_copy_of_its_input(): + source: Final = {"general_settings": {"max_parallel_requests": 5}} + proxy_config: Final = ProxyConfig() + proxy_config.update_config_state(config=source) + source["general_settings"]["max_parallel_requests"] = 6 + + assert proxy_config.get_config_state()["general_settings"]["max_parallel_requests"] == 5 + + @pytest.mark.asyncio async def test_ProxyConfig_save_config_writes_yaml_when_no_db(tmp_path, monkeypatch): target = tmp_path / "out.yaml" @@ -869,6 +1180,25 @@ async def test_ProxyConfig_save_config_writes_yaml_when_no_db(tmp_path, monkeypa assert loaded == cfg +@pytest.mark.asyncio +async def test_ProxyConfig_save_config_writes_a_loadable_yaml_for_a_loaded_config(tmp_path, monkeypatch): + config_file: Final = tmp_path / "config.yaml" + config_file.write_text("general_settings:\n max_parallel_requests: 5\n") + monkeypatch.setattr("litellm.proxy.proxy_server.user_config_file_path", str(config_file)) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None) + monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", False) + monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {}) + proxy_config: Final = ProxyConfig() + loaded_config: Final = await proxy_config.get_config(config_file_path=str(config_file)) + loaded_config["general_settings"]["max_parallel_requests"] = 6 + + await proxy_config.save_config(loaded_config) + + import yaml as _yaml + + assert _yaml.safe_load(config_file.read_text()) == {"general_settings": {"max_parallel_requests": 6}} + + @pytest.mark.asyncio async def test_ProxyConfig_save_config_invalid_path_raises(monkeypatch): monkeypatch.setattr( @@ -885,58 +1215,54 @@ async def test_ProxyConfig_save_config_invalid_path_raises(monkeypatch): @pytest.mark.asyncio async def test_ProxyConfig_save_config_db_omits_environment_variables_by_default(monkeypatch): - """A save_config after get_config() (which resolves os.environ/ placeholders - to plaintext and merges the environment_variables section) must not snapshot - those env vars into the DB config row. Persisting them would make a stale DB - row shadow YAML/container env on every subsequent restart.""" - mock_prisma = MagicMock() - mock_prisma.insert_data = AsyncMock() - monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma) - monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True) - monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {}) - # a valid salt so the env-var encryption path (reached only if the pop - # regresses) runs cleanly, making this fail on the assertion below rather - # than on an incidental encryption crash - monkeypatch.setattr("litellm.proxy.proxy_server.master_key", "sk-test-salt-key") - - pc = ProxyConfig() - cfg = { + proxy_config, table = _db_backed_proxy_config(monkeypatch, {}) + baseline: Final = {"model_list": [], "litellm_settings": {}} + proxy_config.update_config_state(config=baseline) + config: Final = { "model_list": [{"model_name": "gpt-4o"}], "litellm_settings": {"success_callback": ["langfuse"]}, "environment_variables": {"OPENAI_API_KEY": "sk-from-yaml"}, } - await pc.save_config(cfg) - mock_prisma.insert_data.assert_awaited_once() - written = mock_prisma.insert_data.await_args.kwargs["data"] - assert "environment_variables" not in written - # unrelated sections are still persisted; model_list is stripped as before - assert written["litellm_settings"] == {"success_callback": ["langfuse"]} - assert "model_list" not in written - # the caller's dict is not mutated (save_config works on a copy) - assert cfg["environment_variables"] == {"OPENAI_API_KEY": "sk-from-yaml"} + await proxy_config.save_config(config) + + assert table.rows == {"litellm_settings": {"success_callback": ["langfuse"]}} + assert table.upserted_param_names == ["litellm_settings"] + assert config["environment_variables"] == {"OPENAI_API_KEY": "sk-from-yaml"} @pytest.mark.asyncio async def test_ProxyConfig_save_config_db_persists_environment_variables_when_opted_in(monkeypatch): - """The explicit opt-in path (include_env_vars=True) still persists env vars, - encrypted, so the dedicated config-update flow can write them.""" - mock_prisma = MagicMock() - mock_prisma.insert_data = AsyncMock() - monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma) - monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True) - monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {}) + proxy_config, table = _db_backed_proxy_config(monkeypatch, {}) + proxy_config.update_config_state(config={"litellm_settings": {}}) + monkeypatch.setattr("litellm.proxy.proxy_server.master_key", "sk-test-salt-key") + config: Final = {"litellm_settings": {}, "environment_variables": {"OPENAI_API_KEY": "sk-explicit"}} + + await proxy_config.save_config(config, include_env_vars=True) + + assert set(table.rows["environment_variables"]) == {"OPENAI_API_KEY"} + assert table.rows["environment_variables"]["OPENAI_API_KEY"] != "sk-explicit" + assert table.upserted_param_names == ["environment_variables"] + + +@pytest.mark.asyncio +async def test_ProxyConfig_save_config_persists_unchanged_environment_variables_when_opted_in(monkeypatch): + proxy_config, table = _db_backed_proxy_config(monkeypatch, {}) + config: Final = { + "litellm_settings": {}, + "environment_variables": {"OPENAI_API_KEY": "sk-explicit"}, + } monkeypatch.setattr("litellm.proxy.proxy_server.master_key", "sk-test-salt-key") - pc = ProxyConfig() - cfg = {"litellm_settings": {}, "environment_variables": {"OPENAI_API_KEY": "sk-explicit"}} - await pc.save_config(cfg, include_env_vars=True) + await proxy_config.save_config(config) - mock_prisma.insert_data.assert_awaited_once() - written = mock_prisma.insert_data.await_args.kwargs["data"] - assert set(written["environment_variables"].keys()) == {"OPENAI_API_KEY"} - # value is encrypted at rest, not the plaintext it came in as - assert written["environment_variables"]["OPENAI_API_KEY"] != "sk-explicit" + assert table.rows == {} + assert table.upserted_param_names == [] + + await proxy_config.save_config(config, include_env_vars=True) + + assert set(table.rows["environment_variables"]) == {"OPENAI_API_KEY"} + assert table.upserted_param_names == ["environment_variables"] def _install_fake_config_repo(monkeypatch, existing_row): diff --git a/tests/test_litellm/rust_bridge/chat_completions/test_callbacks.py b/tests/test_litellm/rust_bridge/chat_completions/test_route_host.py similarity index 95% rename from tests/test_litellm/rust_bridge/chat_completions/test_callbacks.py rename to tests/test_litellm/rust_bridge/chat_completions/test_route_host.py index 94ac358c6d1..848f5a00eb3 100644 --- a/tests/test_litellm/rust_bridge/chat_completions/test_callbacks.py +++ b/tests/test_litellm/rust_bridge/chat_completions/test_route_host.py @@ -1,7 +1,7 @@ from types import MappingProxyType from typing import Final -from litellm.rust_bridge.chat_completions.callbacks import arguments, response +from litellm.rust_bridge.chat_completions.route_host import arguments, response from litellm.rust_bridge.chat_completions.entrypoints import LiteLLMChatCompletionsRequest from litellm.types.utils import ModelResponse diff --git a/tests/test_litellm/rust_bridge/messages/test_callbacks.py b/tests/test_litellm/rust_bridge/messages/test_route_host.py similarity index 94% rename from tests/test_litellm/rust_bridge/messages/test_callbacks.py rename to tests/test_litellm/rust_bridge/messages/test_route_host.py index 8ba0497ffbe..a880cfe3588 100644 --- a/tests/test_litellm/rust_bridge/messages/test_callbacks.py +++ b/tests/test_litellm/rust_bridge/messages/test_route_host.py @@ -1,7 +1,7 @@ from types import MappingProxyType from typing import Final -from litellm.rust_bridge.messages.callbacks import arguments, response +from litellm.rust_bridge.messages.route_host import arguments, response from litellm.rust_bridge.messages.entrypoints import LiteLLMMessagesRequest diff --git a/tests/test_litellm/rust_bridge/ocr/test_callbacks.py b/tests/test_litellm/rust_bridge/ocr/test_route_host.py similarity index 94% rename from tests/test_litellm/rust_bridge/ocr/test_callbacks.py rename to tests/test_litellm/rust_bridge/ocr/test_route_host.py index a85940aa049..a328579400c 100644 --- a/tests/test_litellm/rust_bridge/ocr/test_callbacks.py +++ b/tests/test_litellm/rust_bridge/ocr/test_route_host.py @@ -3,8 +3,8 @@ from typing import Final import pytest import litellm -from litellm.rust_bridge.ocr.callbacks import UpstreamFailure, map_failure -from litellm.rust_bridge.ocr.callbacks import response as build_ocr_response +from litellm.rust_bridge.ocr.route_host import UpstreamFailure, map_failure +from litellm.rust_bridge.ocr.route_host import response as build_ocr_response from litellm.rust_bridge.ocr.entrypoints import LiteLLMOcrRequest REQUEST: Final = LiteLLMOcrRequest( diff --git a/tests/test_litellm/rust_bridge/responses/test_callbacks.py b/tests/test_litellm/rust_bridge/responses/test_route_host.py similarity index 95% rename from tests/test_litellm/rust_bridge/responses/test_callbacks.py rename to tests/test_litellm/rust_bridge/responses/test_route_host.py index 6ecc5bcf0b9..49bf19e7d8a 100644 --- a/tests/test_litellm/rust_bridge/responses/test_callbacks.py +++ b/tests/test_litellm/rust_bridge/responses/test_route_host.py @@ -4,7 +4,7 @@ from typing import Final import pytest from pydantic import ValidationError -from litellm.rust_bridge.responses.callbacks import arguments, response +from litellm.rust_bridge.responses.route_host import arguments, response from litellm.rust_bridge.responses.entrypoints import LiteLLMResponsesRequest from litellm.types.llms.openai import ResponsesAPIResponse diff --git a/tests/test_litellm/rust_bridge/test_legacy_callbacks.py b/tests/test_litellm/rust_bridge/test_legacy_callbacks.py new file mode 100644 index 00000000000..a4474c85230 --- /dev/null +++ b/tests/test_litellm/rust_bridge/test_legacy_callbacks.py @@ -0,0 +1,79 @@ +import datetime +from collections.abc import Mapping +from types import MappingProxyType +from typing import Final + +import pytest + +import litellm +from litellm.litellm_core_utils.litellm_logging import Logging +from litellm.rust_bridge.legacy_callbacks import check_limits, setup + +_OCR_KWARGS: Final = MappingProxyType( + { + "model": "mistral/mistral-ocr-latest", + "document": {"type": "document_url", "document_url": "data:application/pdf;base64,YWJj"}, + } +) + + +@pytest.mark.parametrize("metadata_key", ["metadata", "litellm_metadata"]) +@pytest.mark.parametrize( + "cap, request_retry_count, refused", + [(5, 5, True), (5, 4, False), (0, 0, False), (0, 1, True)], + ids=[ + "cap-above-four-reached", + "cap-above-four-not-reached", + "first-attempt-passes-cap-of-zero", + "cap-of-zero-refuses-first-retry", + ], +) +def test_check_limits_reads_request_retry_count( + monkeypatch: pytest.MonkeyPatch, metadata_key: str, cap: int, request_retry_count: int, refused: bool +) -> None: + monkeypatch.setattr(litellm, "num_retries_per_request", cap) + monkeypatch.setattr(litellm, "max_budget", None) + kwargs: Final = { + "model": "mistral/mistral-ocr-latest", + metadata_key: {"request_retry_count": request_retry_count}, + } + if refused: + with pytest.raises(RuntimeError, match="Max retries per request hit!"): + check_limits(kwargs) + else: + check_limits(kwargs) + + +def _supplied_logger() -> Logging: + return Logging( + model="mistral/mistral-ocr-latest", + messages=[], + stream=False, + call_type="aocr", + start_time=datetime.datetime.now(), + litellm_call_id="supplied", + function_id="supplied", + ) + + +def test_setup_adopts_a_supplied_logger_as_caller_owned() -> None: + supplied: Final = _supplied_logger() + result: Final = setup( + "aocr", (), {**_OCR_KWARGS, "litellm_logging_obj": supplied}, datetime.datetime.now(), asynchronous=True + ) + assert result.logger is supplied + assert result.bridge_owned is False + + +@pytest.mark.parametrize( + "call_type, kwargs", + [ + ("aocr", _OCR_KWARGS), + ("aembedding", MappingProxyType({"model": "text-embedding-3-large", "input": ["hi"]})), + ], + ids=["ocr", "embedding"], +) +def test_setup_owns_every_logger_it_builds(call_type: str, kwargs: Mapping[str, object]) -> None: + result: Final = setup(call_type, (), kwargs, datetime.datetime.now(), asynchronous=True) + assert result.bridge_owned is True + assert result.logger.litellm_call_id == result.kwargs["litellm_call_id"] diff --git a/tests/test_litellm/rust_bridge/test_lifecycle.py b/tests/test_litellm/rust_bridge/test_lifecycle.py index d73385621d5..4a5a741ba8a 100644 --- a/tests/test_litellm/rust_bridge/test_lifecycle.py +++ b/tests/test_litellm/rust_bridge/test_lifecycle.py @@ -1,33 +1,47 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Sequence from typing import Final -import pytest - -import litellm -from litellm.rust_bridge.lifecycle import check_limits +from litellm.rust_bridge.lifecycle import Await, Complete, drive -@pytest.mark.parametrize("metadata_key", ["metadata", "litellm_metadata"]) -@pytest.mark.parametrize( - "cap, request_retry_count, refused", - [(5, 5, True), (5, 4, False), (0, 0, False), (0, 1, True)], - ids=[ - "cap-above-four-reached", - "cap-above-four-not-reached", - "first-attempt-passes-cap-of-zero", - "cap-of-zero-refuses-first-retry", - ], -) -def test_check_limits_reads_request_retry_count( - monkeypatch: pytest.MonkeyPatch, metadata_key: str, cap: int, request_retry_count: int, refused: bool -) -> None: - monkeypatch.setattr(litellm, "num_retries_per_request", cap) - monkeypatch.setattr(litellm, "max_budget", None) - kwargs: Final = { - "model": "mistral/mistral-ocr-latest", - metadata_key: {"request_retry_count": request_retry_count}, - } - if refused: - with pytest.raises(RuntimeError, match="Max retries per request hit!"): - check_limits(kwargs) - else: - check_limits(kwargs) +class ScriptedExecution: + """Plays scripted steps and records how it was resumed and whether it was closed.""" + + def __init__(self, steps: Sequence[Await | Complete]) -> None: + self._steps: Final = list(steps) + self.resumed: list[tuple[str, object]] = [] + self.closed = False + + def start(self) -> Await | Complete: + return self._steps.pop(0) + + def resume_value(self, value: object) -> Await | Complete: + self.resumed.append(("value", value)) + return self._steps.pop(0) + + def resume_error(self, error: BaseException) -> Await | Complete: + self.resumed.append(("error", type(error))) + return self._steps.pop(0) + + def close(self) -> None: + self.closed = True + + +async def ready(value: object) -> object: + return value + + +async def failing() -> object: + raise ValueError("boom") + + +def test_drive_resumes_each_await_with_its_result_or_error_and_returns_the_completed_value() -> None: + execution: Final = ScriptedExecution([Await(ready(1)), Await(failing()), Complete("done")]) + + assert asyncio.run(drive(execution)) == "done" + + assert execution.resumed == [("value", 1), ("error", ValueError)] + assert execution.closed diff --git a/tests/test_litellm/test_azure_audio_price_aliases.py b/tests/test_litellm/test_azure_audio_price_aliases.py deleted file mode 100644 index b87744aeae1..00000000000 --- a/tests/test_litellm/test_azure_audio_price_aliases.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Undated azure aliases for the audio models must exist and match their dated -variants. Azure deployments are commonly created under an admin-chosen name, so -the served model name means nothing to the cost lookup and `base_model: -azure/gpt-audio-mini` is what prices the call. That key resolved to nothing, the -lookup raised "This model isn't mapped yet", and the proxy logged the request at -$0. Issue #33170.""" - -import json -from pathlib import Path - -import pytest - -import litellm - -pytestmark = pytest.mark.usefixtures("local_model_cost_map") - - -COST_FIELDS = ( - "input_cost_per_token", - "output_cost_per_token", - "input_cost_per_audio_token", - "output_cost_per_audio_token", -) - -ALIAS_PAIRS = ( - ("azure/gpt-audio-mini", "azure/gpt-audio-mini-2025-10-06"), - ("azure/gpt-realtime-mini", "azure/gpt-realtime-mini-2025-10-06"), -) - - -def _load_root_cost_map() -> dict: - root_map_path = Path(__file__).parents[2] / "model_prices_and_context_window.json" - with open(root_map_path) as f: - return json.load(f) - - -@pytest.mark.parametrize("undated, dated", ALIAS_PAIRS) -def test_undated_azure_audio_alias_matches_dated_entry(undated, dated): - undated_info = litellm.get_model_info(undated) - dated_info = litellm.get_model_info(dated) - - for field in COST_FIELDS: - assert undated_info.get(field) == dated_info.get(field), field - assert (undated_info.get(field) or 0) > 0, f"{undated}.{field} must be non-zero" - - assert undated_info.get("litellm_provider") == "azure" - assert undated_info.get("mode") == dated_info.get("mode") - - -@pytest.mark.parametrize("undated, dated", ALIAS_PAIRS) -def test_undated_azure_audio_alias_is_exact_mirror(undated, dated): - """The undated alias must be a byte-for-byte mirror of its dated entry, covering - every field (incl. realtime-specific cache/audio cost keys) so any future drift - between the pair is caught, not just the core COST_FIELDS.""" - model_map = litellm.model_cost - assert undated in model_map, f"{undated} missing from model cost map" - assert model_map[undated] == model_map[dated], ( - f"{undated} must exactly mirror {dated}; " - f"diff keys: {[k for k in set(model_map[undated]) | set(model_map[dated]) if model_map[undated].get(k) != model_map[dated].get(k)]}" - ) - - -@pytest.mark.parametrize("undated, dated", ALIAS_PAIRS) -def test_undated_azure_audio_alias_is_in_the_root_cost_map(undated, dated): - """`local_model_cost_map` pins `litellm.model_cost` to the packaged backup, but a - proxy left on its defaults fetches the root map instead, and that is the copy - that ships to the CDN. An alias added to only one of the two files still bills - $0 for every proxy reading the other, which is the very bug this file guards, so - assert the root map directly and assert the two files agree.""" - root_map = _load_root_cost_map() - assert undated in root_map, f"{undated} missing from the root cost map" - assert root_map[undated] == root_map[dated], f"{undated} must exactly mirror {dated} in the root cost map" - assert root_map[undated] == litellm.model_cost[undated], ( - f"{undated} differs between the root cost map and the packaged backup" - ) diff --git a/tests/test_litellm/test_baseten_glm_5_3_model_metadata.py b/tests/test_litellm/test_baseten_glm_5_3_model_metadata.py index 31f3a67beac..f573c79434a 100644 --- a/tests/test_litellm/test_baseten_glm_5_3_model_metadata.py +++ b/tests/test_litellm/test_baseten_glm_5_3_model_metadata.py @@ -4,7 +4,6 @@ from pathlib import Path import pytest import litellm -from litellm.utils import supports_function_calling, supports_prompt_caching REPO_ROOT = Path(__file__).parents[2] MAIN_PATH = REPO_ROOT / "model_prices_and_context_window.json" @@ -33,17 +32,6 @@ def local_model_cost_map(monkeypatch): litellm.get_model_info.cache_clear() -def test_baseten_glm_5_3_capabilities_are_visible_to_callers(local_model_cost_map): - """The entry advertises prompt caching and tool calling, so the helpers every - caller checks before sending a request must say so too.""" - assert supports_prompt_caching(model=MODEL) is True - assert supports_function_calling(model=MODEL) is True - - info = litellm.get_model_info(model="zai-org/GLM-5.3", custom_llm_provider="baseten") - assert info["max_input_tokens"] > 0 - assert info["max_output_tokens"] > 0 - - def test_backup_matches_main(): """Ensure the bundled (backup) cost map stays in sync with the canonical file. diff --git a/tests/test_litellm/test_bedrock_marengo_embed_3_model_metadata.py b/tests/test_litellm/test_bedrock_marengo_embed_3_model_metadata.py index 1a0e1665556..21e9b26d996 100644 --- a/tests/test_litellm/test_bedrock_marengo_embed_3_model_metadata.py +++ b/tests/test_litellm/test_bedrock_marengo_embed_3_model_metadata.py @@ -3,7 +3,6 @@ from pathlib import Path import pytest -import litellm from litellm.constants import bedrock_embedding_models REPO_ROOT = Path(__file__).parents[2] @@ -31,13 +30,6 @@ def _load(path): return json.load(f) -@pytest.mark.parametrize("model", ALL_MODELS) -def test_marengo_embed_3_is_visible_to_callers(model, local_model_cost_map): - info = litellm.get_model_info(model=model, custom_llm_provider="bedrock") - assert info["mode"] == "embedding" - assert info["output_vector_size"] == 512 - - def test_marengo_embed_3_is_a_known_bedrock_embedding_model(): assert BASE_MODEL in bedrock_embedding_models diff --git a/tests/test_litellm/test_bedrock_usgov_pricing.py b/tests/test_litellm/test_bedrock_usgov_pricing.py deleted file mode 100644 index a3a7fc4ed7a..00000000000 --- a/tests/test_litellm/test_bedrock_usgov_pricing.py +++ /dev/null @@ -1,77 +0,0 @@ -""" -Validate AWS GovCloud (Bedrock us-gov-*) Anthropic pricing entries. - -AWS Bedrock pricing in GovCloud carries a +20% premium over the global -Anthropic prices (not the +10% commercial-US premium). Until 2026-05-22 -these entries silently mirrored commercial US, undercharging customers -by ~9%. - -Source: https://aws.amazon.com/bedrock/pricing/ - - Sonnet 4.5 in us-gov-* (per million tokens): - input = $3.60 - output = $18.00 - cache write 5m = $4.50 - cache write 1h = $7.20 - cache read = $0.36 - -Reference: https://github.com/BerriAI/litellm/issues/27120 -""" - -import json -import os - -import pytest - - -@pytest.fixture(scope="module") -def model_data(): - json_path = os.path.join(os.path.dirname(__file__), "../../model_prices_and_context_window.json") - with open(json_path) as f: - return json.load(f) - - -def test_usgov_east_haiku_profile_mirrors_in_region_row(model_data): - """us-gov-east-1 serves claude-3-haiku through the us-gov. inference profile - only, so the profile row must bill exactly like the in-region gov row. - """ - profile = model_data["us-gov.anthropic.claude-3-haiku-20240307-v1:0"] - in_region = model_data["bedrock/us-gov-east-1/anthropic.claude-3-haiku-20240307-v1:0"] - assert profile["litellm_provider"] == "bedrock_converse" - assert {k: v for k, v in profile.items() if k != "litellm_provider"} == { - k: v for k, v in in_region.items() if k != "litellm_provider" - } - - -GOV_ROW_SOURCES = { - "us-gov.anthropic.claude-fable-5-1": "anthropic.claude-fable-5-1", - "bedrock/us-gov-west-1/anthropic.claude-fable-5-1": "anthropic.claude-fable-5-1", - "bedrock/us-gov-east-1/anthropic.claude-fable-5-1": "anthropic.claude-fable-5-1", - "us-gov.nvidia.nemotron-nano-9b-v2": "nvidia.nemotron-nano-9b-v2", - "bedrock/us-gov-west-1/nvidia.nemotron-nano-9b-v2": "nvidia.nemotron-nano-9b-v2", - "bedrock/us-gov-east-1/nvidia.nemotron-nano-9b-v2": "nvidia.nemotron-nano-9b-v2", - "us-gov.xai.grok-4.6": "us.xai.grok-4.6", - "bedrock_mantle/us-gov-west-1/xai.grok-4.6": "bedrock_mantle/xai.grok-4.6", - "bedrock_mantle/us-gov-east-1/xai.grok-4.6": "bedrock_mantle/xai.grok-4.6", - "bedrock/us-gov-west-1/amazon.nova-2-multimodal-embeddings-v1:0": "amazon.nova-2-multimodal-embeddings-v1:0", - "bedrock/us-gov-west-1/amazon.nova-lite-v1:0": "amazon.nova-lite-v1:0", - "bedrock/us-gov-west-1/amazon.nova-micro-v1:0": "amazon.nova-micro-v1:0", - "bedrock_mantle/us-gov-west-1/google.gemma-4-e2b": "bedrock_mantle/google.gemma-4-e2b", - "bedrock_mantle/us-gov-west-1/google.gemma-4-26b-a4b": "bedrock_mantle/google.gemma-4-26b-a4b", - "bedrock_mantle/us-gov-west-1/google.gemma-4-31b": "bedrock_mantle/google.gemma-4-31b", - "bedrock_mantle/us-gov-west-1/openai.gpt-oss-20b": "bedrock_mantle/openai.gpt-oss-20b", - "bedrock_mantle/us-gov-east-1/openai.gpt-oss-20b": "bedrock_mantle/openai.gpt-oss-20b", - "bedrock_mantle/us-gov-west-1/openai.gpt-oss-120b": "bedrock_mantle/openai.gpt-oss-120b", - "bedrock_mantle/us-gov-east-1/openai.gpt-oss-120b": "bedrock_mantle/openai.gpt-oss-120b", -} - - -def _non_pricing_fields(info): - return {k: v for k, v in info.items() if "cost" not in k and k not in ("litellm_provider", "source")} - - -@pytest.mark.parametrize("gov_key", GOV_ROW_SOURCES) -def test_usgov_rows_keep_commercial_limits_and_capabilities(model_data, gov_key): - """Gov rows preserve the commercial row's non-pricing fields.""" - gov = model_data[gov_key] - assert _non_pricing_fields(gov) == _non_pricing_fields(model_data[GOV_ROW_SOURCES[gov_key]]) diff --git a/tests/test_litellm/test_claude_fable_5_config.py b/tests/test_litellm/test_claude_fable_5_config.py index 4b03848da2c..dfbda795c7a 100644 --- a/tests/test_litellm/test_claude_fable_5_config.py +++ b/tests/test_litellm/test_claude_fable_5_config.py @@ -26,67 +26,10 @@ def _load_root_cost_map() -> dict: return json.load(f) -def test_fable_5_present_in_bundled_backup(): - """The bundled backup is the runtime fallback (and what tests load with - ``LITELLM_LOCAL_MODEL_COST_MAP=True``) — it must carry the same entries as - the root cost map, otherwise the model resolves on one path but not the - other.""" - backup = GetModelCostMap.load_local_model_cost_map() - root = _load_root_cost_map() - for model_name in ( - "claude-fable-5", - "anthropic.claude-fable-5", - "global.anthropic.claude-fable-5", - "us.anthropic.claude-fable-5", - "eu.anthropic.claude-fable-5", - "vertex_ai/claude-fable-5", - "vertex_ai/claude-fable-5@default", - "azure_ai/claude-fable-5", - ): - assert model_name in backup, f"Missing from backup cost map: {model_name}" - assert backup[model_name] == root[model_name], model_name - - def test_fable_5_registered_for_bedrock_converse(): assert "anthropic.claude-fable-5" in BEDROCK_CONVERSE_MODELS -@pytest.mark.parametrize( - "cost_map", - [_load_root_cost_map(), GetModelCostMap.load_local_model_cost_map()], - ids=["root", "bundled_backup"], -) -def test_fable_5_all_variants_carry_adaptive_thinking_flag(cost_map): - """Every Fable 5 entry must advertise ``supports_adaptive_thinking``. - - Adaptive-thinking detection is cost-map driven, so a single variant missing - the flag silently sends the legacy ``thinking.type='enabled'`` shape and the - provider 400s (issue #29188 for the Opus 4.8 equivalent). Fable 5 is even - stricter than Opus 4.8: an explicit ``thinking.type='disabled'`` also 400s, - so adaptive is the only valid thinking shape LiteLLM can emit for it.""" - variants = [k for k in cost_map if "claude-fable-5" in k] - assert variants, "no claude-fable-5 entries found in cost map" - missing = [k for k in variants if cost_map[k].get("supports_adaptive_thinking") is not True] - assert not missing, f"missing supports_adaptive_thinking: {missing}" - - -@pytest.mark.parametrize( - "cost_map", - [_load_root_cost_map(), GetModelCostMap.load_local_model_cost_map()], - ids=["root", "bundled_backup"], -) -def test_fable_5_all_variants_carry_thinking_always_on_flag(cost_map): - """Every Fable 5 entry must advertise ``thinking_always_on``. - - The flag drives the Anthropic transformations to omit an explicit - ``thinking.type='disabled'``, which Fable 5 rejects with a 400; a variant - missing the flag forwards the param verbatim and the provider 400s.""" - variants = [k for k in cost_map if "claude-fable-5" in k] - assert variants, "no claude-fable-5 entries found in cost map" - missing = [k for k in variants if cost_map[k].get("thinking_always_on") is not True] - assert not missing, f"missing thinking_always_on: {missing}" - - @pytest.mark.parametrize( "model", [ @@ -151,22 +94,3 @@ def test_adaptive_thinking_detected_for_fable_5_1(local_model_cost_map, model): assert AnthropicModelInfo._is_adaptive_thinking_model(model, "anthropic") is True -@pytest.mark.parametrize( - "cost_map", - [_load_root_cost_map(), GetModelCostMap.load_local_model_cost_map()], - ids=["root", "bundled_backup"], -) -def test_sampling_params_flag_on_all_models_that_removed_them(cost_map): - """Fable 5 and Opus 4.7/4.8 reject ``top_p``/``top_k``/``temperature != 1``; - the drop/raise gating is cost-map driven, so every variant must carry an - explicit ``supports_sampling_params: false``. The perplexity route is - exempt: it is OpenAI-compatible and maps sampling params upstream.""" - variants = [ - k - for k in cost_map - if any(v in k for v in ("claude-fable-5", "claude-opus-4-7", "claude-opus-4-8")) - and not k.startswith("perplexity/") - ] - assert variants, "no matching entries found in cost map" - missing = [k for k in variants if cost_map[k].get("supports_sampling_params") is not False] - assert not missing, f"missing supports_sampling_params=false: {missing}" diff --git a/tests/test_litellm/test_claude_haiku_4_5_config.py b/tests/test_litellm/test_claude_haiku_4_5_config.py deleted file mode 100644 index d0b7f4f8a2c..00000000000 --- a/tests/test_litellm/test_claude_haiku_4_5_config.py +++ /dev/null @@ -1,46 +0,0 @@ -""" -Test Claude Haiku 4.5 model configurations for Bedrock -https://github.com/BerriAI/litellm/issues/15818 -""" - -import json -import os - - -def test_bedrock_haiku_4_5_matches_sonnet_capabilities(): - """ - Test that Haiku 4.5 has same capabilities as Sonnet 4.5 - (including computer_use, vision, tools, etc.) - """ - # Load model configuration - json_path = os.path.join(os.path.dirname(__file__), "../../model_prices_and_context_window.json") - with open(json_path) as f: - model_data = json.load(f) - - haiku_model = "us.anthropic.claude-haiku-4-5-20251001-v1:0" - sonnet_model = "us.anthropic.claude-sonnet-4-5-20250929-v1:0" - - haiku_info = model_data[haiku_model] - sonnet_info = model_data[sonnet_model] - - # Both should use bedrock_converse - assert haiku_info["litellm_provider"] == "bedrock_converse" - assert sonnet_info["litellm_provider"] == "bedrock_converse" - - # Shared capabilities that should match - shared_capabilities = [ - "supports_vision", - "supports_computer_use", - "supports_function_calling", - "supports_tool_choice", - "supports_prompt_caching", - "supports_response_schema", - "supports_pdf_input", - "supports_assistant_prefill", - "supports_reasoning", - ] - - for capability in shared_capabilities: - assert haiku_info.get(capability) == sonnet_info.get(capability), ( - f"Capability {capability} mismatch: Haiku={haiku_info.get(capability)}, Sonnet={sonnet_info.get(capability)}" - ) diff --git a/tests/test_litellm/test_claude_opus_4_6_config.py b/tests/test_litellm/test_claude_opus_4_6_config.py index 9a8632924f2..7bded3b6ed3 100644 --- a/tests/test_litellm/test_claude_opus_4_6_config.py +++ b/tests/test_litellm/test_claude_opus_4_6_config.py @@ -2,100 +2,10 @@ Validate Claude Opus 4.6 model configuration entries. """ -import json -import os import litellm -def test_claude_4_6_australia_region_uses_au_prefix_not_apac(): - """ - Test that Australia region Claude 4.6 models use 'au.' prefix instead of incorrect 'apac.' prefix. - - AWS Bedrock cross-region inference uses specific regional prefixes: - - 'us.' for United States - - 'eu.' for Europe - - 'au.' for Australia (ap-southeast-2) - - 'apac.' for Asia-Pacific (Singapore, ap-southeast-1) - - This test ensures the Claude 4.6 models correctly use 'au.' for Australia, - and that 'apac.' is NOT incorrectly used for Australia region. - - Related: The 'apac.' prefix is valid for Asia-Pacific (Singapore) region models, - but should not be used for Australia which has its own 'au.' prefix. - """ - json_path = os.path.join( - os.path.dirname(__file__), "../../model_prices_and_context_window.json" - ) - with open(json_path) as f: - model_data = json.load(f) - - # Verify au.anthropic.claude-opus-4-6-v1 exists (correct) - assert ( - "au.anthropic.claude-opus-4-6-v1" in model_data - ), "Missing Australia region model: au.anthropic.claude-opus-4-6-v1" - - # Verify apac.anthropic.claude-opus-4-6-v1 does NOT exist (incorrect) - assert ( - "apac.anthropic.claude-opus-4-6-v1" not in model_data - ), "Incorrect model entry exists: apac.anthropic.claude-opus-4-6-v1 should be au.anthropic.claude-opus-4-6-v1" - - # Verify au.anthropic.claude-sonnet-4-6 exists (correct) - assert ( - "au.anthropic.claude-sonnet-4-6" in model_data - ), "Missing Australia region model: au.anthropic.claude-sonnet-4-6" - - # Verify apac.anthropic.claude-sonnet-4-6 does NOT exist (incorrect) - assert ( - "apac.anthropic.claude-sonnet-4-6" not in model_data - ), "Incorrect model entry exists: apac.anthropic.claude-sonnet-4-6 should be au.anthropic.claude-sonnet-4-6" - - # Verify the au. model is registered in bedrock_converse_models - assert ( - "au.anthropic.claude-opus-4-6-v1" in litellm.bedrock_converse_models - ), "au.anthropic.claude-opus-4-6-v1 not registered in bedrock_converse_models" - - # Verify apac. is NOT registered for this model - assert ( - "apac.anthropic.claude-opus-4-6-v1" not in litellm.bedrock_converse_models - ), "apac.anthropic.claude-opus-4-6-v1 should not be in bedrock_converse_models" - - # Verify the au. model is registered in bedrock_converse_models - assert ( - "au.anthropic.claude-sonnet-4-6" in litellm.bedrock_converse_models - ), "au.anthropic.claude-sonnet-4-6 not registered in bedrock_converse_models" - - # Verify apac. is NOT registered for this model - assert ( - "apac.anthropic.claude-sonnet-4-6" not in litellm.bedrock_converse_models - ), "apac.anthropic.claude-sonnet-4-6 should not be in bedrock_converse_models" - - -def test_opus_4_6_alias_and_dated_metadata_match(): - json_path = os.path.join( - os.path.dirname(__file__), "../../model_prices_and_context_window.json" - ) - with open(json_path) as f: - model_data = json.load(f) - - alias = model_data["claude-opus-4-6"] - dated = model_data["claude-opus-4-6-20260205"] - - keys_to_match = [ - "max_input_tokens", - "max_output_tokens", - "max_tokens", - "input_cost_per_token", - "output_cost_per_token", - "cache_creation_input_token_cost", - "cache_creation_input_token_cost_above_1hr", - "cache_read_input_token_cost", - "supports_assistant_prefill", - ] - for key in keys_to_match: - assert alias[key] == dated[key], f"Mismatch for {key}" - - def test_opus_4_6_bedrock_converse_registration(): assert "anthropic.claude-opus-4-6-v1" in litellm.BEDROCK_CONVERSE_MODELS assert "global.anthropic.claude-opus-4-6-v1" in litellm.bedrock_converse_models diff --git a/tests/test_litellm/test_claude_opus_4_8_config.py b/tests/test_litellm/test_claude_opus_4_8_config.py index 1a4bab249fd..9471ef4ef4f 100644 --- a/tests/test_litellm/test_claude_opus_4_8_config.py +++ b/tests/test_litellm/test_claude_opus_4_8_config.py @@ -11,43 +11,15 @@ for Anthropic, Bedrock, Vertex AI, and Azure AI; those entries are what populate in ``get_llm_provider`` consumes. """ -import json import os -import pytest from litellm.constants import BEDROCK_CONVERSE_MODELS -from litellm.litellm_core_utils.get_model_cost_map import GetModelCostMap REPO_ROOT = os.path.join(os.path.dirname(__file__), "../..") -def _load_root_cost_map() -> dict: - json_path = os.path.join(REPO_ROOT, "model_prices_and_context_window.json") - with open(json_path) as f: - return json.load(f) - - def test_opus_4_8_registered_for_bedrock_converse(): assert "anthropic.claude-opus-4-8" in BEDROCK_CONVERSE_MODELS -@pytest.mark.parametrize( - "cost_map", - [_load_root_cost_map(), GetModelCostMap.load_local_model_cost_map()], - ids=["root", "bundled_backup"], -) -def test_opus_4_8_all_variants_carry_adaptive_thinking_flag(cost_map): - """Every Opus 4.8 entry must advertise ``supports_adaptive_thinking``. - - Adaptive-thinking detection is cost-map driven, so a single variant missing - the flag silently sends the legacy ``thinking.type='enabled'`` shape and the - provider 400s (issue #29188, which the Bedrock/Vertex/Azure variants hit - because only the bare ``claude-opus-4-8`` entry carried the flag). This guards - against a future variant being added without it.""" - variants = [k for k in cost_map if "claude-opus-4-8" in k] - assert variants, "no claude-opus-4-8 entries found in cost map" - missing = [ - k for k in variants if cost_map[k].get("supports_adaptive_thinking") is not True - ] - assert not missing, f"missing supports_adaptive_thinking: {missing}" diff --git a/tests/test_litellm/test_claude_opus_5_config.py b/tests/test_litellm/test_claude_opus_5_config.py index 07e493af914..aaf179e0216 100644 --- a/tests/test_litellm/test_claude_opus_5_config.py +++ b/tests/test_litellm/test_claude_opus_5_config.py @@ -12,13 +12,11 @@ validator accepts the full effort ladder, so the entries must not carry the ``anthropic/*`` wildcard deployment). """ -import json import os import pytest from litellm.constants import BEDROCK_CONVERSE_MODELS -from litellm.litellm_core_utils.get_model_cost_map import GetModelCostMap REPO_ROOT = os.path.join(os.path.dirname(__file__), "../..") @@ -45,12 +43,6 @@ BEDROCK_OPUS_5_VARIANTS = ( ) -def _load_root_cost_map() -> dict: - json_path = os.path.join(REPO_ROOT, "model_prices_and_context_window.json") - with open(json_path) as f: - return json.load(f) - - @pytest.mark.parametrize("model_name", BEDROCK_OPUS_5_VARIANTS) def test_opus_5_bedrock_rejects_strict_tools(model_name, local_model_cost_map): """Bedrock Converse routes Opus through a validator that rejects @@ -62,31 +54,7 @@ def test_opus_5_bedrock_rejects_strict_tools(model_name, local_model_cost_map): assert bedrock_converse_supports_strict_tools(model_name) is False -def test_opus_5_present_in_bundled_backup(): - """The bundled backup is the runtime fallback (and what tests load with - ``LITELLM_LOCAL_MODEL_COST_MAP=True``); it must carry the same entries as the - root cost map, otherwise the model resolves on one path but not the other.""" - backup = GetModelCostMap.load_local_model_cost_map() - for model_name in ALL_OPUS_5_VARIANTS: - assert model_name in backup, f"Missing from backup cost map: {model_name}" - - def test_opus_5_registered_for_bedrock_converse(): assert "anthropic.claude-opus-5" in BEDROCK_CONVERSE_MODELS -@pytest.mark.parametrize( - "cost_map", - [_load_root_cost_map(), GetModelCostMap.load_local_model_cost_map()], - ids=["root", "bundled_backup"], -) -def test_opus_5_all_variants_carry_adaptive_thinking_flag(cost_map): - """Every Opus 5 entry must advertise ``supports_adaptive_thinking``. - - Adaptive-thinking detection is cost-map driven, so a single variant missing - the flag silently sends the legacy ``thinking.type='enabled'`` shape, which - Opus 5 rejects with a 400.""" - variants = [k for k in cost_map if "claude-opus-5" in k] - assert variants, "no claude-opus-5 entries found in cost map" - missing = [k for k in variants if cost_map[k].get("supports_adaptive_thinking") is not True] - assert not missing, f"missing supports_adaptive_thinking: {missing}" diff --git a/tests/test_litellm/test_claude_sonnet_4_6_config.py b/tests/test_litellm/test_claude_sonnet_4_6_config.py deleted file mode 100644 index a669c21be30..00000000000 --- a/tests/test_litellm/test_claude_sonnet_4_6_config.py +++ /dev/null @@ -1,38 +0,0 @@ -""" -Test Claude Sonnet 4.6 model configurations for Bedrock cross-region inference. - -Pins the set of region-prefixed entries in model_prices_and_context_window.json -so future drops of a region (or pricing drift between regions) is caught. - -https://github.com/BerriAI/litellm/issues/22972 -""" - -import json -import os - - -def test_bedrock_sonnet_4_6_jp_matches_other_regional_pricing(): - """The jp. cross-region inference profile shares pricing with the other - regional profiles (us./eu./au.), which carry a 10% premium over the - base/global entries. - """ - json_path = os.path.join( - os.path.dirname(__file__), "../../model_prices_and_context_window.json" - ) - with open(json_path) as f: - model_data = json.load(f) - - jp_info = model_data["jp.anthropic.claude-sonnet-4-6"] - au_info = model_data["au.anthropic.claude-sonnet-4-6"] - - pricing_fields = [ - "input_cost_per_token", - "output_cost_per_token", - "cache_creation_input_token_cost", - "cache_read_input_token_cost", - ] - for field in pricing_fields: - assert jp_info[field] == au_info[field], ( - f"{field} mismatch between jp. and au. variants: " - f"jp={jp_info[field]}, au={au_info[field]}" - ) diff --git a/tests/test_litellm/test_claude_sonnet_5_config.py b/tests/test_litellm/test_claude_sonnet_5_config.py index 8c6d2cd1851..5e7d5797a62 100644 --- a/tests/test_litellm/test_claude_sonnet_5_config.py +++ b/tests/test_litellm/test_claude_sonnet_5_config.py @@ -10,13 +10,10 @@ populate ``litellm.anthropic_models`` at import, which is what lets a bare ``anthropic/*`` wildcard deployment). """ -import json import os -import pytest from litellm.constants import BEDROCK_CONVERSE_MODELS -from litellm.litellm_core_utils.get_model_cost_map import GetModelCostMap REPO_ROOT = os.path.join(os.path.dirname(__file__), "../..") @@ -34,37 +31,7 @@ ALL_SONNET_5_VARIANTS = ( ) -def _load_root_cost_map() -> dict: - json_path = os.path.join(REPO_ROOT, "model_prices_and_context_window.json") - with open(json_path) as f: - return json.load(f) - - -def test_sonnet_5_present_in_bundled_backup(): - """The bundled backup is the runtime fallback (and what tests load with - ``LITELLM_LOCAL_MODEL_COST_MAP=True``); it must carry the same entries as the - root cost map, otherwise the model resolves on one path but not the other.""" - backup = GetModelCostMap.load_local_model_cost_map() - for model_name in ALL_SONNET_5_VARIANTS: - assert model_name in backup, f"Missing from backup cost map: {model_name}" - - def test_sonnet_5_registered_for_bedrock_converse(): assert "anthropic.claude-sonnet-5" in BEDROCK_CONVERSE_MODELS -@pytest.mark.parametrize( - "cost_map", - [_load_root_cost_map(), GetModelCostMap.load_local_model_cost_map()], - ids=["root", "bundled_backup"], -) -def test_sonnet_5_all_variants_carry_adaptive_thinking_flag(cost_map): - """Every Sonnet 5 entry must advertise ``supports_adaptive_thinking``. - - Adaptive-thinking detection is cost-map driven, so a single variant missing - the flag silently sends the legacy ``thinking.type='enabled'`` shape and the - provider 400s. This guards against a future variant being added without it.""" - variants = [k for k in cost_map if "claude-sonnet-5" in k] - assert variants, "no claude-sonnet-5 entries found in cost map" - missing = [k for k in variants if cost_map[k].get("supports_adaptive_thinking") is not True] - assert not missing, f"missing supports_adaptive_thinking: {missing}" diff --git a/tests/test_litellm/test_cost_calculator.py b/tests/test_litellm/test_cost_calculator.py index ff28e69a909..b6bd03adc86 100644 --- a/tests/test_litellm/test_cost_calculator.py +++ b/tests/test_litellm/test_cost_calculator.py @@ -27,7 +27,6 @@ from litellm.types.utils import ( PromptTokensDetailsWrapper, Usage, ) -from litellm.utils import TranscriptionResponse @pytest.fixture @@ -2375,28 +2374,6 @@ def test_anthropic_geo_and_fast_multipliers_compose(_local_model_cost_map, monke assert completion_cost == pytest.approx(500 * 25e-6 * 2.0 * 1.1) -@pytest.mark.parametrize( - "model,expected_fast", - [ - ("claude-opus-5", 2.0), - ("claude-opus-4-8", 2.0), - ("claude-opus-4-6", None), - ("claude-opus-4-6-20260205", None), - ("claude-opus-4-7", None), - ("claude-opus-4-7-20260416", None), - ], -) -def test_anthropic_fast_multiplier_only_on_models_with_fast_mode(_local_model_cost_map, model, expected_fast): - """ - Anthropic serves fast mode on Opus 5 and Opus 4.8 only, at 2x. Opus 4.6 and - 4.7 accept the ``speed`` request param but are always served standard, so a - ``fast`` multiplier on their map entries overbills every request that asked - for fast and was served standard. - """ - entry = litellm.model_cost[model] - assert entry["provider_specific_entry"].get("fast") == expected_fast - - @pytest.mark.parametrize( "model", ["claude-sonnet-4-6", "claude-mythos-5", "claude-mythos-preview"], @@ -3376,24 +3353,6 @@ def test_combine_usage_objects_sums_mirrored_cache_write_fields_once(): assert combined_pair.prompt_tokens_details.cache_creation_tokens == 100 -def _together_chat_response( - model: str, prompt_tokens: int, completion_tokens: int, cached_tokens: int -) -> ModelResponse: - return ModelResponse( - id="chatcmpl-together-cache", - choices=[{"finish_reason": "stop", "index": 0, "message": {"content": "acknowledged", "role": "assistant"}}], - created=1756164000, - model=model, - object="chat.completion", - usage=Usage( - prompt_tokens=prompt_tokens, - completion_tokens=completion_tokens, - total_tokens=prompt_tokens + completion_tokens, - prompt_tokens_details=PromptTokensDetailsWrapper(cached_tokens=cached_tokens), - ), - ) - - def test_select_model_name_strips_unregistered_alias_prefix(_local_model_cost_map): """A router-facing model_name alias containing "/" whose leading segment is NOT a registered provider must not be double-prefixed into a non-existent cost key. diff --git a/tests/test_litellm/test_dashscope_image_generation.py b/tests/test_litellm/test_dashscope_image_generation.py index 119efa010e0..1dd0b322623 100644 --- a/tests/test_litellm/test_dashscope_image_generation.py +++ b/tests/test_litellm/test_dashscope_image_generation.py @@ -5,7 +5,6 @@ qwen-image-3.0, qwen-image-3.0-pro). Run in docker: pytest tests/test_litellm/test_dashscope_image_generation.py -v """ -import json from unittest.mock import MagicMock, patch import httpx @@ -16,7 +15,7 @@ from litellm.llms.dashscope.image_generation.transformation import ( DashScopeImageGenerationConfig, DEFAULT_API_BASE, ) -from litellm.types.utils import ImageObject, ImageResponse +from litellm.types.utils import ImageResponse from litellm.utils import get_llm_provider from litellm.llms.base_llm.chat.transformation import BaseLLMException @@ -46,40 +45,6 @@ def test_get_llm_provider_returns_dashscope(model_string: str): # --------------------------------------------------------------------------- -@pytest.mark.parametrize( - "model_string, custom_provider", - [ - ("dashscope/qwen-image-2.0", "dashscope"), - ("dashscope/qwen-image-2.0-pro", "dashscope"), - ("dashscope/qwen-image-3.0", "dashscope"), - ("dashscope/qwen-image-3.0-pro", "dashscope"), - ], -) -def test_get_model_info_mode_is_image_generation( - model_string: str, custom_provider: str -): - import os - - prev_env = os.environ.get("LITELLM_LOCAL_MODEL_COST_MAP") - prev_model_cost = litellm.model_cost - try: - os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True" - litellm.model_cost = litellm.get_model_cost_map(url="") - - info = litellm.get_model_info( - model=model_string, custom_llm_provider=custom_provider - ) - assert ( - info["mode"] == "image_generation" - ), f"Expected mode='image_generation', got '{info['mode']}'" - finally: - if prev_env is None: - os.environ.pop("LITELLM_LOCAL_MODEL_COST_MAP", None) - else: - os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = prev_env - litellm.model_cost = prev_model_cost - - # --------------------------------------------------------------------------- # 3. Request transformation # --------------------------------------------------------------------------- diff --git a/tests/test_litellm/test_deepseek_model_metadata.py b/tests/test_litellm/test_deepseek_model_metadata.py index 264f5e65fc5..91ed54b826c 100644 --- a/tests/test_litellm/test_deepseek_model_metadata.py +++ b/tests/test_litellm/test_deepseek_model_metadata.py @@ -15,7 +15,6 @@ import os import litellm from litellm.utils import ( _supports_factory, - supports_response_schema, ) # --------------------------------------------------------------------------- @@ -59,18 +58,6 @@ class TestSupportsResponseSchemaDeepSeek: """All calling conventions for DeepSeek should return True for ``supports_response_schema``.""" - def test_provider_slash_model(self): - assert supports_response_schema(model="deepseek/deepseek-chat") is True - - def test_explicit_provider(self): - assert supports_response_schema(model="deepseek-chat", custom_llm_provider="deepseek") is True - - def test_reasoner_provider_slash_model(self): - assert supports_response_schema(model="deepseek/deepseek-reasoner") is True - - def test_reasoner_explicit_provider(self): - assert supports_response_schema(model="deepseek-reasoner", custom_llm_provider="deepseek") is True - # --------------------------------------------------------------------------- # Fallback-logic test – bare model entry used when prefixed is incomplete diff --git a/tests/test_litellm/test_mistral_zai_glm_5_2_model_metadata.py b/tests/test_litellm/test_mistral_zai_glm_5_2_model_metadata.py index 8467cbd43b1..c5fe247aa51 100644 --- a/tests/test_litellm/test_mistral_zai_glm_5_2_model_metadata.py +++ b/tests/test_litellm/test_mistral_zai_glm_5_2_model_metadata.py @@ -4,7 +4,6 @@ from pathlib import Path import pytest import litellm -from litellm.utils import supports_prompt_caching, supports_reasoning REPO_ROOT = Path(__file__).parents[2] MAIN_PATH = REPO_ROOT / "model_prices_and_context_window.json" @@ -33,16 +32,6 @@ def local_model_cost_map(monkeypatch): litellm.get_model_info.cache_clear() -@pytest.mark.parametrize("model", GLM_5_2_MODELS) -def test_zai_glm_5_2_capabilities_are_visible_to_callers(local_model_cost_map, model): - """Mistral advertises reasoning and prompt caching on this model, so the helpers - every caller checks before sending a request must say so too.""" - assert supports_reasoning(model=model) is True - assert supports_prompt_caching(model=model) is True - - assert litellm.get_model_info(model=model) - - @pytest.mark.parametrize("model", GLM_5_2_MODELS) def test_backup_matches_main(model): """Ensure the bundled (backup) cost map stays in sync with the canonical file.""" diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index 4618c156046..876c36b1071 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -162,15 +162,6 @@ def test_prompt_tokens_details_cache_write_creation_stay_in_sync_on_assignment() assert details.cache_write_tokens == details.cache_creation_tokens == 375 -def test_get_model_info_surfaces_supported_endpoints(local_model_cost_map): - """supported_endpoints ships in the cost map and is declared on ModelInfoBase, - but the constructor never copied it, so get_model_info always returned None. - The realtime health check reads it to spot GA-only transcription models - (LIT-6240).""" - info = litellm.get_model_info(model="gpt-realtime-whisper", custom_llm_provider="azure") - assert info["supported_endpoints"] == ["/v1/realtime", "/v1/realtime/transcription_sessions"] - - def test_potential_model_names_keeps_provider_prefixed_candidate(): """A provider whose own model ids repeat the litellm provider name (Perplexity's Agent API serves `perplexity/glm-5.2`, mapped as `perplexity/perplexity/glm-5.2`) @@ -236,23 +227,6 @@ def test_check_provider_match_github_allows_upstream_provider_metadata(): ) -def test_supports_function_calling_github_openai_alias(): - assert litellm.utils.supports_function_calling(model="github/gpt-4o-mini") is True - assert litellm.utils.supports_function_calling(model="gpt-4o-mini", custom_llm_provider="github") is True - - -def test_supports_function_calling_github_anthropic_alias(): - assert litellm.utils.supports_function_calling(model="github/claude-3-7-sonnet-20250219") is True - - -def test_supports_function_calling_deepinfra_llama(): - """Test that deepinfra Llama models correctly report function calling support. - - Regression test for https://github.com/BerriAI/litellm/issues/22619 - """ - assert litellm.utils.supports_function_calling(model="deepinfra/meta-llama/Llama-3.3-70B-Instruct-Turbo") is True - - def test_supports_function_calling_unknown_github_alias_returns_false(): assert litellm.utils.supports_function_calling(model="github/non-existent-model-for-capability-check") is False @@ -565,25 +539,6 @@ def test_all_model_configs(): ) == {"max_output_tokens": 10} -def test_anthropic_web_search_in_model_info(monkeypatch): - monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") - litellm.model_cost = litellm.get_model_cost_map(url="") - - supported_models = [ - "anthropic/claude-4-sonnet-20250514", - "anthropic/claude-sonnet-4-5-20250929", - ] - for model in supported_models: - from litellm.utils import get_model_info - - model_info = get_model_info(model) - assert model_info is not None - assert model_info["supports_web_search"] is True, f"Model {model} should support web search" - assert model_info["search_context_cost_per_query"] is not None, ( - f"Model {model} should have a search context cost per query" - ) - - def test_cohere_embedding_optional_params(): from litellm import get_optional_params_embeddings @@ -1129,13 +1084,6 @@ def test_get_model_info_bedrock_regional_inference_profile_pricing(local_model_c assert control["key"] == "au.anthropic.claude-opus-4-8" -def test_get_model_info_bedrock_double_provider_prefix_resolves(local_model_cost_map): - """A doubled bedrock/ prefix routes at runtime via strip_bedrock_routing_prefix, - so model info must resolve it to the same entry the request actually bills as.""" - info = litellm.get_model_info(model="bedrock/bedrock/us.anthropic.claude-sonnet-4-6") - assert info["key"] == "us.anthropic.claude-sonnet-4-6" - - def test_openai_models_in_model_info(monkeypatch): monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") litellm.model_cost = litellm.get_model_cost_map(url="") @@ -1149,51 +1097,6 @@ def test_openai_models_in_model_info(monkeypatch): assert len(violated_models) == 0, f"The following models should support pdf input: {violated_models}" -def test_supports_tool_choice_simple_tests(): - """ - simple sanity checks - """ - assert litellm.utils.supports_tool_choice(model="gpt-4o") == True - assert litellm.utils.supports_tool_choice(model="bedrock/anthropic.claude-3-sonnet-20240229-v1:0") == True - assert litellm.utils.supports_tool_choice(model="anthropic.claude-3-sonnet-20240229-v1:0") is True - - assert ( - litellm.utils.supports_tool_choice( - model="anthropic.claude-3-sonnet-20240229-v1:0", - custom_llm_provider="bedrock_converse", - ) - is True - ) - - assert litellm.utils.supports_tool_choice(model="perplexity/sonar") is False - - -@pytest.mark.usefixtures("local_model_cost_map") -@pytest.mark.parametrize( - "model", - [ - "amazon.nova-lite-v1:0", - "amazon.nova-micro-v1:0", - "amazon.nova-pro-v1:0", - "apac.amazon.nova-lite-v1:0", - "apac.amazon.nova-micro-v1:0", - "apac.amazon.nova-pro-v1:0", - "bedrock/us-gov-east-1/amazon.nova-pro-v1:0", - "bedrock/us-gov-west-1/amazon.nova-lite-v1:0", - "bedrock/us-gov-west-1/amazon.nova-micro-v1:0", - "bedrock/us-gov-west-1/amazon.nova-pro-v1:0", - "eu.amazon.nova-lite-v1:0", - "eu.amazon.nova-micro-v1:0", - "eu.amazon.nova-pro-v1:0", - "us.amazon.nova-lite-v1:0", - "us.amazon.nova-micro-v1:0", - "us.amazon.nova-pro-v1:0", - ], -) -def test_amazon_nova_v1_understanding_models_support_tool_choice(model: str) -> None: - assert litellm.utils.supports_tool_choice(model=model) is True - - def test_check_provider_match(): """ Test the _check_provider_match function for various provider scenarios @@ -1303,42 +1206,6 @@ for commitment in BEDROCK_COMMITMENTS: print("block_list", block_list) -def test_supports_computer_use_utility(monkeypatch): - """ - Tests the litellm.utils.supports_computer_use utility function. - """ - from litellm.utils import supports_computer_use - - # Ensure LITELLM_LOCAL_MODEL_COST_MAP is set for consistent test behavior, - # as supports_computer_use relies on get_model_info. - # This also requires litellm.model_cost to be populated. - original_env_var = os.getenv("LITELLM_LOCAL_MODEL_COST_MAP") - original_model_cost = getattr(litellm, "model_cost", None) - - monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") - litellm.model_cost = litellm.get_model_cost_map(url="") # Load with local/backup - - try: - # Test a model known to support computer_use from backup JSON - supports_cu_anthropic = supports_computer_use(model="anthropic/claude-4-sonnet-20250514") - assert supports_cu_anthropic is True - - # Test a model known not to have the flag or set to false (defaults to False via get_model_info) - supports_cu_gpt = supports_computer_use(model="gpt-3.5-turbo") - assert supports_cu_gpt is False - finally: - # Restore original environment and model_cost to avoid side effects - if original_env_var is None: - del os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] - else: - monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", original_env_var) - - if original_model_cost is not None: - litellm.model_cost = original_model_cost - elif hasattr(litellm, "model_cost"): - delattr(litellm, "model_cost") - - @pytest.mark.parametrize( "model, custom_llm_provider", [ @@ -1658,32 +1525,6 @@ class TestProxyFunctionCalling: # For now, we expect False (current behavior), but document the limitation assert proxy_result is False, f"Current limitation: {proxy_model_with_hints} returns False without inference" - @pytest.mark.parametrize( - "proxy_model,expected_result", - [ - # Test specific proxy models that should support function calling - ("litellm_proxy/gpt-3.5-turbo", True), - ("litellm_proxy/gpt-4", True), - ("litellm_proxy/gpt-4o", True), - ("litellm_proxy/claude-sonnet-4-6", True), - ("litellm_proxy/gemini/gemini-2.5-pro", True), - # Test proxy models that should not support function calling - ("litellm_proxy/command-nightly", False), - ("litellm_proxy/anthropic.claude-instant-v1", False), - ], - ) - def test_proxy_only_function_calling_support(self, proxy_model, expected_result): - """ - Test proxy models independently to ensure they report correct function calling support. - - This test focuses on proxy models without comparing to direct models, - useful for cases where we only care about the proxy behavior. - """ - try: - result = supports_function_calling(model=proxy_model) - assert result == expected_result, f"Proxy model {proxy_model} returned {result}, expected {expected_result}" - except Exception as e: - pytest.fail(f"Error testing proxy model {proxy_model}: {e}") def test_litellm_utils_supports_function_calling_import(self): """Test that supports_function_calling can be imported from litellm.utils.""" @@ -1704,28 +1545,6 @@ class TestProxyFunctionCalling: except Exception as e: pytest.fail(f"Failed to access litellm.supports_function_calling: {e}") - @pytest.mark.parametrize( - "model_name", - [ - "litellm_proxy/gpt-3.5-turbo", - "litellm_proxy/gpt-4", - "litellm_proxy/claude-sonnet-4-6", - "litellm_proxy/gemini/gemini-2.5-pro", - ], - ) - def test_proxy_model_with_custom_llm_provider_none(self, model_name): - """ - Test proxy models with custom_llm_provider=None parameter. - - This tests the supports_function_calling function with the custom_llm_provider - parameter explicitly set to None, which is a common usage pattern. - """ - try: - result = supports_function_calling(model=model_name, custom_llm_provider=None) - # All the models in this test should support function calling - assert result is True, f"Model {model_name} should support function calling but returned {result}" - except Exception as e: - pytest.fail(f"Error testing {model_name} with custom_llm_provider=None: {e}") def test_edge_cases_and_malformed_proxy_models(self): """Test edge cases and malformed proxy model names.""" @@ -1963,84 +1782,6 @@ class TestProxyFunctionCalling: f"(without config context). Description: {description}" ) - def test_real_world_proxy_config_documentation(self): - """ - Document how real-world proxy configurations would handle model mappings. - - This test provides documentation on how the proxy server configuration - would typically map custom model names to underlying models. - """ - print(""" - - REAL-WORLD PROXY SERVER CONFIGURATION EXAMPLE: - =============================================== - - In a proxy_server_config.yaml file, you would define: - - model_list: - - model_name: bedrock-claude-3-haiku - litellm_params: - model: bedrock/converse/anthropic.claude-3-haiku-20240307-v1:0 - aws_access_key_id: os.environ/AWS_ACCESS_KEY_ID - aws_secret_access_key: os.environ/AWS_SECRET_ACCESS_KEY - aws_region_name: us-east-1 - - - model_name: bedrock-claude-3-sonnet - litellm_params: - model: bedrock/converse/anthropic.claude-3-sonnet-20240229-v1:0 - aws_access_key_id: os.environ/AWS_ACCESS_KEY_ID - aws_secret_access_key: os.environ/AWS_SECRET_ACCESS_KEY - aws_region_name: us-east-1 - - - model_name: prod-claude-haiku - litellm_params: - model: bedrock/converse/anthropic.claude-3-haiku-20240307-v1:0 - aws_access_key_id: os.environ/PROD_AWS_ACCESS_KEY_ID - aws_secret_access_key: os.environ/PROD_AWS_SECRET_ACCESS_KEY - aws_region_name: us-west-2 - - - FUNCTION CALLING WITH PROXY SERVER: - =================================== - - When using the proxy server with this configuration: - - 1. Client calls: supports_function_calling("bedrock-claude-3-haiku") - 2. Proxy server resolves to: bedrock/converse/anthropic.claude-3-haiku-20240307-v1:0 - 3. LiteLLM evaluates the underlying model's capabilities - 4. Returns: True (because Claude 3 Haiku supports function calling) - - Without the proxy server configuration context, LiteLLM cannot resolve - the custom model name and returns False. - - - BEDROCK CONVERSE API BENEFITS: - ============================== - - The Bedrock Converse API provides: - - Standardized function calling interface across providers - - Better tool use capabilities compared to legacy APIs - - Consistent request/response format - - Enhanced streaming support for function calls - - """) - - # Verify that direct underlying models work as expected - bedrock_models = [ - "bedrock/converse/anthropic.claude-3-haiku-20240307-v1:0", - "bedrock/converse/anthropic.claude-3-sonnet-20240229-v1:0", - "bedrock/converse/anthropic.claude-sonnet-4-5-20250929-v1:0", - ] - - for model in bedrock_models: - try: - result = supports_function_calling(model) - print(f"Direct test - {model}: {result}") - # Claude 3 models should support function calling - assert result is True, f"Claude 3 model should support function calling: {model}" - except Exception as e: - print(f"Could not test {model}: {e}") - def test_register_model_with_scientific_notation(): """ @@ -3637,28 +3378,6 @@ _FIREWORKS_ROUTER_SHORT_FORMS = [ ] -def _assert_fireworks_entry( - model_cost, - model_path, - expected_max_input, - expected_max_output, - expected_vision, - expected_reasoning, -): - info = model_cost.get(f"fireworks_ai/{model_path}") - assert info is not None, f"fireworks_ai/{model_path} missing from model cost map" - assert info["litellm_provider"] == "fireworks_ai" - assert info["mode"] == "chat" - assert info["input_cost_per_token"] > 0 - assert info["output_cost_per_token"] > 0 - assert "cache_read_input_token_cost" in info - assert info["supports_function_calling"] is True - assert info["supports_tool_choice"] is True - assert info["supports_reasoning"] is expected_reasoning - assert info["supports_response_schema"] is True - assert info["supports_vision"] is expected_vision - - @pytest.fixture def fireworks_short_model_cost_map(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: monkeypatch.setattr( @@ -3985,21 +3704,6 @@ def test_get_prompt_cache_min_tokens_resolves_per_model( assert get_prompt_cache_min_tokens(model=model) == expected_min_tokens -def test_get_prompt_cache_min_tokens_uniform_for_fable_5_across_platforms(local_model_cost_map: None) -> None: - """Anthropic removed the Amazon Bedrock override for Claude Fable 5, so its 512-token minimum - now applies on every platform. The Bedrock entries carried the old 1024 and the re-export - entries carried nothing, so the router judged 512-1023-token prefixes uncacheable and skipped - prompt-cache-affinity routing for prompts the provider demonstrably caches (issue #35011).""" - wrong: Final = { - model: get_prompt_cache_min_tokens(model=model) - for model, info in litellm.model_cost.items() - if "fable-5" in model - and info.get("supports_prompt_caching") - and get_prompt_cache_min_tokens(model=model) != 512 - } - assert not wrong, f"every Claude Fable 5 entry must carry prompt_cache_min_tokens 512: {wrong}" - - ANTHROPIC_REEXPORT_CACHE_MIN: Final = { "azure_ai/claude-fable-5": 512, "azure_ai/claude-haiku-4-5": 4096, @@ -4048,21 +3752,6 @@ ANTHROPIC_REEXPORT_CACHE_MIN: Final = { } -def test_anthropic_reexport_entries_carry_explicit_prompt_cache_min_tokens(local_model_cost_map: None) -> None: - """Regression for issue #35011: these re-export entries carried no prompt_cache_min_tokens, so - they silently inherited the 1024 default. That skipped cache-affinity routing for Fable 5's - 512-1023-token prefixes and reported 1024-4095-token prompts as cacheable on the 2048/4096 - models. The entry must be explicit so a default change can never re-break them, which is why - this asserts the cost-map value itself and not just the resolver's answer.""" - wrong: Final = { - model: (litellm.model_cost[model].get("prompt_cache_min_tokens"), get_prompt_cache_min_tokens(model=model)) - for model, expected in ANTHROPIC_REEXPORT_CACHE_MIN.items() - if litellm.model_cost[model].get("prompt_cache_min_tokens") != expected - or get_prompt_cache_min_tokens(model=model) != expected - } - assert not wrong, f"(cost-map value, resolved value) diverge from Anthropic's published minimums: {wrong}" - - GEMINI_4096_CACHE_MIN_MODELS: Final = tuple( prefix + base for base in ( @@ -5981,82 +5670,6 @@ def test_completion_finishes_response_metadata_before_handing_the_response_to_th assert snapshot["api_base"] -def test_fireworks_models_in_backup_cost_map(): - import json - from pathlib import Path - - json_path = Path(__file__).parents[2] / "litellm" / "model_prices_and_context_window_backup.json" - with open(json_path) as f: - model_cost = json.load(f) - - for entry in _FIREWORKS_MODELS: - _assert_fireworks_entry(model_cost, *entry) - - for short in _FIREWORKS_SHORT_FORMS: - long_key = f"fireworks_ai/accounts/fireworks/models/{short}" - short_key = f"fireworks_ai/{short}" - assert model_cost.get(short_key) == model_cost.get(long_key), ( - f"short-form {short_key} does not match long-form {long_key}" - ) - - for short in _FIREWORKS_ROUTER_SHORT_FORMS: - long_key = f"fireworks_ai/accounts/fireworks/routers/{short}" - short_key = f"fireworks_ai/{short}" - assert model_cost.get(short_key) == model_cost.get(long_key), ( - f"short-form {short_key} does not match long-form {long_key}" - ) - - -def test_fireworks_models_in_cost_map(): - import json - from pathlib import Path - - json_path = Path(__file__).parents[2] / "model_prices_and_context_window.json" - with open(json_path) as f: - model_cost = json.load(f) - - for entry in _FIREWORKS_MODELS: - _assert_fireworks_entry(model_cost, *entry) - - for short in _FIREWORKS_SHORT_FORMS: - long_key = f"fireworks_ai/accounts/fireworks/models/{short}" - short_key = f"fireworks_ai/{short}" - assert model_cost.get(short_key) == model_cost.get(long_key), ( - f"short-form {short_key} does not match long-form {long_key}" - ) - - for short in _FIREWORKS_ROUTER_SHORT_FORMS: - long_key = f"fireworks_ai/accounts/fireworks/routers/{short}" - short_key = f"fireworks_ai/{short}" - assert model_cost.get(short_key) == model_cost.get(long_key), ( - f"short-form {short_key} does not match long-form {long_key}" - ) - - -def test_fireworks_short_model_names_resolve_to_long_cost_map_keys(fireworks_short_model_cost_map: None) -> None: - model_info = litellm.get_model_info("fireworks_ai/glm-5p3") - assert model_info["key"] == "fireworks_ai/accounts/fireworks/models/glm-5p3" - - model_info = litellm.get_model_info("glm-5p3", custom_llm_provider="fireworks_ai") - assert model_info["key"] == "fireworks_ai/accounts/fireworks/models/glm-5p3" - - model_info = litellm.get_model_info("fireworks_ai/glm-5p3-fast") - assert model_info["key"] == "fireworks_ai/accounts/fireworks/routers/glm-5p3-fast" - - model_info = litellm.get_model_info("fireworks_ai/nomic-ai/nomic-embed-text-v1.5") - assert model_info["key"] == "fireworks_ai/nomic-ai/nomic-embed-text-v1.5" - - with pytest.raises(Exception, match="isn't mapped"): - litellm.get_model_info("fireworks_ai/does-not-exist") - - -def test_get_model_info_bedrock_regional_profile_without_entry_falls_back_to_base(local_model_cost_map): - """A regional profile with no dedicated cost-map entry must still resolve to its - region-stripped base entry.""" - info = litellm.get_model_info(model="bedrock/apac.anthropic.claude-opus-4-8") - assert info["key"] == "anthropic.claude-opus-4-8" - - def test_get_model_info_gemini(monkeypatch): """ Tests if ALL gemini models have 'tpm' and 'rpm' in the model info @@ -6079,153 +5692,3 @@ def test_get_model_info_gemini(monkeypatch): assert info.get("rpm") is not None, f"{model} does not have rpm" -def test_get_model_info_resolves_provider_prefixed_model_ids(local_model_cost_map): - """Perplexity's Agent API third-party models are keyed `perplexity/perplexity/` - because Perplexity's own id already starts with `perplexity/`. Callers run - `get_llm_provider` first, which hands `_get_potential_model_names` model - `perplexity/glm-5.2` with provider `perplexity`, and every candidate but the - provider-prefixed one strips that second `perplexity/` off. Regression: the - entries were unreachable from `supports_reasoning` and from the cost calculator's - per-token fallback, so a mapped model reported no reasoning support and raised - "This model isn't mapped yet" on the only path where its rates are ever used.""" - for model, reasoning in ( - ("perplexity/perplexity/glm-5.2", True), - ("perplexity/perplexity/kimi-k3", True), - ("perplexity/perplexity/deepseek-v4-flash-0731", True), - ("perplexity/perplexity/kimi-k2.7-code", False), - ("perplexity/perplexity/nemotron-3.5-lightning-30b-a3b", True), - ("perplexity/perplexity/nemotron-3-ultra-550b-a55b", True), - ): - assert litellm.supports_reasoning(model=model) is reasoning, model - - via_provider = litellm.get_model_info(model="perplexity/glm-5.2", custom_llm_provider="perplexity") - assert via_provider["key"] == "perplexity/perplexity/glm-5.2" - assert via_provider["mode"] == "responses" - - lightning = litellm.get_model_info( - model="perplexity/nemotron-3.5-lightning-30b-a3b", custom_llm_provider="perplexity" - ) - assert lightning["key"] == "perplexity/perplexity/nemotron-3.5-lightning-30b-a3b" - assert lightning["mode"] == "responses" - - ultra = litellm.get_model_info(model="perplexity/perplexity/nemotron-3-ultra-550b-a55b") - assert ultra["key"] == "perplexity/perplexity/nemotron-3-ultra-550b-a55b" - - -def test_get_model_info_shows_supports_computer_use(monkeypatch): - """ - Tests if 'supports_computer_use' is correctly retrieved by get_model_info. - We'll use 'claude-4-sonnet-20250514' as it's configured - in the backup JSON to have supports_computer_use: True. - """ - monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") - # Ensure litellm.model_cost is loaded, relying on the backup mechanism if primary fails - # as per previous debugging. - litellm.model_cost = litellm.get_model_cost_map(url="") - - # This model should have 'supports_computer_use': True in the backup JSON - model_known_to_support_computer_use = "claude-4-sonnet-20250514" - info = litellm.get_model_info(model_known_to_support_computer_use) - - # After the fix in utils.py, this should now be present and True - assert info.get("supports_computer_use") is True - - -def test_get_model_info_surfaces_supports_adaptive_thinking(local_model_cost_map): - """supports_adaptive_thinking must flow through get_model_info like every other - capability flag: both from an explicit cost-map entry and from a - fallback-generalization rule for an unmapped model. Regression: the field shipped - in the JSON but was never declared on ModelInfo nor copied during construction, so - get_model_info (and _supports_factory) silently dropped it for any provider-prefixed - or unmapped name.""" - explicit = litellm.get_model_info(model="claude-opus-4-8") - assert explicit["supports_adaptive_thinking"] is True - - generalized = litellm.get_model_info(model="claude-opus-4-9", custom_llm_provider="anthropic") - assert generalized["supports_adaptive_thinking"] is True - - -def test_get_model_info_surfaces_supports_parallel_function_calling(local_model_cost_map): - """A registry entry's supports_parallel_function_calling must read back through get_model_info - and litellm.supports_parallel_function_calling. Regression: the key was never copied into - ModelInfo, so provider-prefixed entries read None / False even when the map said True, and an - explicit False was indistinguishable from unset.""" - declared_true = litellm.get_model_info(model="together_ai/zai-org/GLM-5.3-Flash") - assert declared_true["supports_parallel_function_calling"] is True - assert litellm.supports_parallel_function_calling(model="together_ai/zai-org/GLM-5.3-Flash") is True - - -def test_model_info_for_fireworks_short_form_models(): - """ - Test that fireworks_ai short-form model entries (fireworks_ai/) - are correctly configured in model_prices_and_context_window.json. - - These entries enable cost attribution for models called via short-form - names (e.g., fireworks_ai/glm-4p7 instead of - fireworks_ai/accounts/fireworks/models/glm-4p7). - """ - import json - from pathlib import Path - - json_path = Path(__file__).parents[2] / "model_prices_and_context_window.json" - with open(json_path) as f: - model_cost = json.load(f) - - # glm-4p7: short-form and long-form - for key in [ - "fireworks_ai/glm-4p7", - "fireworks_ai/accounts/fireworks/models/glm-4p7", - ]: - info = model_cost.get(key) - assert info is not None, f"{key} not found in model_prices_and_context_window.json" - assert info["litellm_provider"] == "fireworks_ai" - assert info["mode"] == "chat" - assert info["supports_reasoning"] is True - - # minimax-m2p1: short-form and long-form - for key in [ - "fireworks_ai/minimax-m2p1", - "fireworks_ai/accounts/fireworks/models/minimax-m2p1", - ]: - info = model_cost.get(key) - assert info is not None, f"{key} not found in model_prices_and_context_window.json" - assert info["litellm_provider"] == "fireworks_ai" - assert info["mode"] == "chat" - - # kimi-k2p5: short-form only (long-form already existed) - info = model_cost.get("fireworks_ai/kimi-k2p5") - assert info is not None, "fireworks_ai/kimi-k2p5 not found in model_prices_and_context_window.json" - assert info["litellm_provider"] == "fireworks_ai" - assert info["mode"] == "chat" - - -def test_model_info_for_vertex_ai_deepseek_model(): - model_info = litellm.get_model_info(model="vertex_ai/deepseek-ai/deepseek-r1-0528-maas") - assert model_info is not None - assert model_info["litellm_provider"] == "vertex_ai-deepseek_models" - assert model_info["mode"] == "chat" - - assert model_info["input_cost_per_token"] is not None - assert model_info["output_cost_per_token"] is not None - - -def test_provider_prefixed_lookup_never_outranks_an_existing_row(local_model_cost_map): - """The provider-prefixed candidate is tried last, after every candidate that - already existed, so no model that resolves today can change answer. `perplexity/sonar` - is the case that proves it: both `perplexity/sonar` and `perplexity/perplexity/sonar` - are cost-map keys, and the shorter one must keep winning.""" - sonar = litellm.get_model_info(model="sonar", custom_llm_provider="perplexity") - assert sonar["key"] == "perplexity/sonar" - assert sonar["mode"] == "chat" - - still_sonar = litellm.get_model_info(model="perplexity/sonar", custom_llm_provider="perplexity") - assert still_sonar["key"] == "perplexity/sonar" - assert still_sonar["mode"] == "chat" - - for model, provider, expected_key in ( - ("claude-sonnet-4-5", "anthropic", "claude-sonnet-4-5"), - ("anthropic/claude-sonnet-4-5", "anthropic", "claude-sonnet-4-5"), - ("gemini/gemini-2.0-flash", "gemini", "gemini/gemini-2.0-flash"), - ("openrouter/openai/gpt-4o", "openrouter", "openrouter/openai/gpt-4o"), - ): - assert litellm.get_model_info(model=model, custom_llm_provider=provider)["key"] == expected_key diff --git a/tests/test_litellm_rust/ocr/test_callbacks.py b/tests/test_litellm_rust/ocr/test_callbacks.py index 1cfd04b1bff..27cdcc4d997 100644 --- a/tests/test_litellm_rust/ocr/test_callbacks.py +++ b/tests/test_litellm_rust/ocr/test_callbacks.py @@ -96,29 +96,6 @@ def test_native_ocr_pre_call_header_edit_reaches_next_callback_and_provider(ocr_ assert ocr_server.requests[0].headers["x-audit-tag"] == "reviewed" -def test_native_ocr_pre_call_header_rebinding_does_not_replace_execution_root(ocr_server: RecordingServer) -> None: - retained: Final = [] - observed: Final = [] - - class RetainMutateAndRebind(CustomLogger): - def log_pre_api_call(self, model, messages, kwargs): - headers = request_headers(kwargs) - retained.append(headers) - kwargs["additional_args"]["headers"] = {"x-rebound": "not-sent"} - headers["x-retained"] = "sent" - - class ObserveRebinding(CustomLogger): - def log_pre_api_call(self, model, messages, kwargs): - observed.append(dict(request_headers(kwargs))) - - call_native_ocr_with_callbacks(ocr_server, [RetainMutateAndRebind(), ObserveRebinding()]) - - assert observed == [{"x-rebound": "not-sent"}] - assert retained[0]["x-retained"] == "sent" - assert ocr_server.requests[0].headers["x-retained"] == "sent" - assert "x-rebound" not in ocr_server.requests[0].headers - - @pytest.mark.asyncio @pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"]) async def test_native_ocr_pre_call_nested_document_edit_updates_caller_callback_and_provider_references( @@ -158,30 +135,6 @@ async def test_native_ocr_pre_call_nested_document_edit_updates_caller_callback_ assert response.pages[0].markdown == "native OCR response" -def test_native_ocr_pre_call_document_replacement_does_not_mutate_original_document( - ocr_server: RecordingServer, -) -> None: - original: Final = dict(OCR_DOCUMENT) - replacement: Final = {"type": "document_url", "document_url": "data:application/pdf;base64,ZGVm"} - retained: Final = [] - - class RetainAndReplace(CustomLogger): - def log_pre_api_call(self, model, messages, kwargs): - body = request_body(kwargs) - retained.append(body["document"]) - body["document"] = replacement - - call_native_ocr( - ocr_server, - document=original, - callbacks=[RetainAndReplace()], - ) - - assert retained[0] is original - assert original["document_url"] == OCR_DOCUMENT["document_url"] - assert ocr_server.requests[0].body["document"] == replacement - - def test_native_ocr_pre_call_body_rebinding_is_visible_to_callbacks_but_not_provider( ocr_server: RecordingServer, ) -> None: @@ -319,32 +272,6 @@ async def test_native_aocr_failure_callbacks_receive_state_added_by_pre_call_cal assert all(observed_token is token for _, observed_token in observed) -@pytest.mark.asyncio -async def test_native_aocr_callback_error_does_not_mask_provider_error_or_skip_later_failure_callbacks( - ocr_server: RecordingServer, -) -> None: - ocr_server.enqueue(ResponseSpec(body={"message": "provider unavailable"}, status=500)) - recorder: Final = RecordingLogger() - - class FailingCallback(CustomLogger): - def log_failure_event(self, kwargs, response_obj, start_time, end_time): - raise RuntimeError("failure callback failed") - - async def async_log_failure_event(self, kwargs, response_obj, start_time, end_time): - raise RuntimeError("failure callback failed") - - with pytest.raises(litellm.InternalServerError) as caught: - await call_native_aocr_with_callbacks(ocr_server, [FailingCallback(), recorder]) - - sync_events: Final = tuple(event for event in recorder.events if event.name == "log_failure_event") - async_events: Final = tuple(event for event in recorder.events if event.name == "async_log_failure_event") - assert len(sync_events) == 1 - assert len(async_events) == 1 - assert sync_events[0].kwargs["exception"] is caught.value - assert async_events[0].kwargs["exception"] is caught.value - assert "async_log_success_event" not in recorder.names - - def test_native_ocr_dispatches_each_callback_phase_once_when_logger_is_registered_multiple_times( ocr_server: RecordingServer, ) -> None: @@ -372,6 +299,7 @@ async def test_native_azure_ocr_resolves_token_before_pre_call_on_caller_context asynchronous: bool, ) -> None: from contextvars import ContextVar + context: Final = ContextVar("azure-token-context", default="missing") context.set("caller") caller_thread: Final = threading.current_thread() @@ -400,9 +328,7 @@ async def test_native_azure_ocr_resolves_token_before_pre_call_on_caller_context "callbacks": [Edit()], } response: Final = ( - await call_native_aocr(ocr_server, **arguments) - if asynchronous - else call_native_ocr(ocr_server, **arguments) + await call_native_aocr(ocr_server, **arguments) if asynchronous else call_native_ocr(ocr_server, **arguments) ) assert response.pages[0].markdown == "native OCR response" assert observations == ["token", "pre_call"] @@ -431,9 +357,7 @@ async def test_native_azure_ocr_token_provider_can_make_nested_native_ocr_call( "azure_ad_token_provider": provider, } response: Final = ( - await call_native_aocr(ocr_server, **arguments) - if asynchronous - else call_native_ocr(ocr_server, **arguments) + await call_native_aocr(ocr_server, **arguments) if asynchronous else call_native_ocr(ocr_server, **arguments) ) assert response.pages[0].markdown == "native OCR response" assert calls == ["token"] @@ -480,53 +404,36 @@ async def test_concurrent_native_azure_ocr_calls_isolate_token_results_and_error @pytest.mark.asyncio -@pytest.mark.parametrize("outcome", ["success", "failure", "cancellation"]) -async def test_native_azure_ocr_releases_token_provider_after_terminal_outcome( +async def test_native_azure_ocr_releases_token_provider_after_cancellation( ocr_server: RecordingServer, isolated_azure_auth: None, - outcome: str, ) -> None: import gc import weakref + from tests.test_litellm_rust.support.callback_recorder import drain_logging + class Provider: def __call__(self) -> str: - if outcome == "failure": - raise ValueError("unavailable") return "caller-token" async def invoke() -> weakref.ReferenceType[Provider]: provider: Final = Provider() reference: Final = weakref.ref(provider) - if outcome == "failure": - ocr_server.expected_requests = 0 - with pytest.raises(litellm.APIConnectionError): - await call_native_aocr( - ocr_server, model="azure_ai/mistral-ocr-latest", api_key=None, azure_ad_token_provider=provider - ) - elif outcome == "cancellation": - ocr_server.enqueue(ResponseSpec(body=OCR_RESPONSE, delay=0.1)) - task: Final = asyncio.create_task( - call_native_aocr( - ocr_server, - model="azure_ai/mistral-ocr-latest", - api_key=None, - azure_ad_token_provider=provider, - ) - ) - await ocr_server.wait_for_requests(1) - assert reference() is provider - task.cancel() - with pytest.raises(asyncio.CancelledError): - await task - else: - response: Final = await call_native_aocr( + ocr_server.enqueue(ResponseSpec(body=OCR_RESPONSE, delay=0.1)) + task: Final = asyncio.create_task( + call_native_aocr( ocr_server, model="azure_ai/mistral-ocr-latest", api_key=None, azure_ad_token_provider=provider, ) - assert response.pages[0].markdown == "native OCR response" + ) + await ocr_server.wait_for_requests(1) + assert reference() is provider + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task return reference reference: Final = await invoke() diff --git a/tests/test_litellm_rust/ocr/test_cohere.py b/tests/test_litellm_rust/ocr/test_cohere.py index 2a35dc62bd1..8474e971c6f 100644 --- a/tests/test_litellm_rust/ocr/test_cohere.py +++ b/tests/test_litellm_rust/ocr/test_cohere.py @@ -21,87 +21,6 @@ PAYLOAD: Final = { } -@pytest.mark.asyncio -@pytest.mark.parametrize("model", MODELS) -@pytest.mark.parametrize("asynchronous", [False, True]) -async def test_public_cohere_request_and_normalization( - recording_server: RecordingServer, model: str, asynchronous: bool -) -> None: - recording_server.enqueue(ResponseSpec(body=PAYLOAD)) - args: Final = { - "model": model, - "document": IMAGE, - "api_base": recording_server.base_url, - "api_key": "test-key", - "req_format": "native", - "unrecognized": True, - } - response: Final = await litellm.aocr(**args) if asynchronous else litellm.ocr(**args) - request: Final = recording_server.requests[0] - assert request.path == ("/providers/cohere/v2/parse" if model.startswith("azure_ai/") else "/v2/parse") - assert request.headers["authorization"] == "Bearer test-key" - assert request.body == {"model": model.split("/", 1)[1], "document": IMAGE, "output_format": "markdown"} - assert [page.index for page in response.pages] == [4, 1] - assert response.pages[0].markdown == "receipt" - assert response.pages[0].images[0].bbox == BOX - assert response.pages[0].images[0].model_extra["description"] == "scan" - assert response.pages[1].images is None - assert response.usage_info.pages_processed == 3 - assert response.get_provider_native_response() == PAYLOAD - - -@pytest.mark.asyncio -@pytest.mark.parametrize("model", MODELS) -async def test_public_cohere_blocks_and_usage_fallback(recording_server: RecordingServer, model: str) -> None: - blocks: Final = [{"type": "text", "text": "total"}] - recording_server.enqueue(ResponseSpec(body={"pages": [{"blocks": blocks}]})) - response: Final = await litellm.aocr( - model=model, document=IMAGE, api_base=recording_server.base_url, api_key="test-key", output_format="blocks" - ) - assert recording_server.requests[0].body["output_format"] == "blocks" - assert response.pages[0].model_extra["blocks"] == blocks - assert response.pages[0].markdown == "" - assert response.usage_info.pages_processed == 1 - assert response.get_provider_native_response() is None - - -@pytest.mark.asyncio -@pytest.mark.parametrize("model", MODELS) -@pytest.mark.parametrize( - "document", - [ - {"type": "document_url", "document_url": "https://example.com/file.pdf"}, - {"type": "image_url", "image_url": "data:application/pdf;base64,YQ=="}, - {"type": "image_url", "image_url": ""}, - ], -) -async def test_public_cohere_rejects_non_images_before_network( - recording_server: RecordingServer, model: str, document: dict[str, str] -) -> None: - recording_server.expected_requests = 0 - with pytest.raises(litellm.BadRequestError, match="only accepts `image_url`"): - await litellm.aocr(model=model, document=document, api_base=recording_server.base_url, api_key="test-key") - - -@pytest.mark.asyncio -@pytest.mark.parametrize("model", MODELS) -async def test_public_cohere_rejects_unknown_format(recording_server: RecordingServer, model: str) -> None: - recording_server.expected_requests = 0 - with pytest.raises(litellm.BadRequestError, match="output_format"): - await litellm.aocr( - model=model, document=IMAGE, api_base=recording_server.base_url, api_key="test-key", output_format="html" - ) - - -@pytest.mark.asyncio -@pytest.mark.parametrize("model", MODELS) -async def test_public_cohere_provider_failure(recording_server: RecordingServer, model: str) -> None: - recording_server.enqueue(ResponseSpec(status=400, body={"message": "output_format must be blocks or markdown"})) - with pytest.raises(litellm.BadRequestError, match="output_format must be") as caught: - await litellm.aocr(model=model, document=IMAGE, api_base=recording_server.base_url, api_key="test-key") - assert caught.value.status_code == 400 - - @pytest.mark.asyncio @pytest.mark.parametrize("model", MODELS) async def test_public_cohere_health_check(recording_server: RecordingServer, model: str) -> None: @@ -111,31 +30,3 @@ async def test_public_cohere_health_check(recording_server: RecordingServer, mod ) assert "error" not in response assert recording_server.requests[0].body["document"]["image_url"].startswith("data:image/png;base64,") - - -@pytest.mark.asyncio -@pytest.mark.parametrize("suffix", ["", "/cohere/", "/v2", "/v2/parse"]) -async def test_public_cohere_url_variants(recording_server: RecordingServer, suffix: str) -> None: - recording_server.enqueue(ResponseSpec(body=PAYLOAD)) - await litellm.aocr(model=MODELS[0], document=IMAGE, api_base=recording_server.base_url + suffix, api_key="test-key") - assert recording_server.requests[0].path == ("/cohere/v2/parse" if suffix == "/cohere/" else "/v2/parse") - - -@pytest.mark.asyncio -async def test_public_cohere_environment_key_and_remote_url( - recording_server: RecordingServer, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.setenv("COHERE_API_KEY", "env-key") - recording_server.enqueue(ResponseSpec(body=PAYLOAD)) - document: Final = {"type": "image_url", "image_url": "https://example.com/receipt.png"} - await litellm.aocr(model=MODELS[0], document=document, api_base=recording_server.base_url) - assert recording_server.requests[0].headers["authorization"] == "Bearer env-key" - assert recording_server.requests[0].body["document"] == document - - -@pytest.mark.asyncio -async def test_public_cohere_missing_key(recording_server: RecordingServer, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("COHERE_API_KEY", raising=False) - recording_server.expected_requests = 0 - with pytest.raises(Exception, match="Missing COHERE_API_KEY"): - await litellm.aocr(model=MODELS[0], document=IMAGE, api_base=recording_server.base_url) diff --git a/tests/test_litellm_rust/ocr/test_guardrails.py b/tests/test_litellm_rust/ocr/test_guardrails.py index f6fc1c7cb8d..de4590ba202 100644 --- a/tests/test_litellm_rust/ocr/test_guardrails.py +++ b/tests/test_litellm_rust/ocr/test_guardrails.py @@ -10,7 +10,7 @@ from litellm.types.guardrails import BlockedWord, ContentFilterAction, Guardrail from litellm.types.utils import CallTypes from tests.test_litellm_rust.support.callback_recorder import RecordingLogger from tests.test_litellm_rust.support.recording_server import RecordingServer, ResponseSpec -from tests.test_litellm_rust.support.requests import OCR_RESPONSE, call_native_aocr +from tests.test_litellm_rust.support.requests import OCR_RESPONSE, call_native, call_native_aocr pytestmark = pytest.mark.requires_rust_extension diff --git a/tests/test_litellm_rust/ocr/test_lifecycle.py b/tests/test_litellm_rust/ocr/test_lifecycle.py index aa9794a73a6..085ea4a14c0 100644 --- a/tests/test_litellm_rust/ocr/test_lifecycle.py +++ b/tests/test_litellm_rust/ocr/test_lifecycle.py @@ -2,7 +2,6 @@ import asyncio import datetime import gc import json -import sys import threading import weakref from collections.abc import Coroutine @@ -23,41 +22,6 @@ from tests.test_litellm_rust.support.requests import OCR_RESPONSE, call_aocr, ca pytestmark = pytest.mark.requires_rust_extension -@pytest.mark.asyncio -@pytest.mark.parametrize("phase", ["deployment", "failure"]) -async def test_cancellation_during_failure_obeys_phase_policy(ocr_server: RecordingServer, phase: str) -> None: - ocr_server.enqueue(ResponseSpec(body={"message": "provider failure"}, status=500)) - entered: Final = asyncio.Event() - observed: Final = [] - - class Observer(CustomLogger): - async def async_post_call_failure_deployment_hook(self, request_data, exception, call_type, **kwargs): - if phase == "deployment": - entered.set() - await asyncio.Event().wait() - - async def async_log_failure_event(self, kwargs, response_obj, start_time, end_time): - observed.append(kwargs["exception"]) - if phase == "failure": - entered.set() - await asyncio.Event().wait() - - observer: Final = Observer() - litellm.callbacks.append(observer) - task: Final = asyncio.create_task(call_aocr(ocr_server, callbacks=[observer])) - await asyncio.wait_for(entered.wait(), 5) - task.cancel() - if phase == "deployment": - with pytest.raises(litellm.InternalServerError) as caught: - await task - assert observed == [caught.value] - else: - with pytest.raises(asyncio.CancelledError): - await task - assert len(observed) == 1 - assert isinstance(observed[0], litellm.InternalServerError) - - @pytest.fixture def ocr_server(recording_server: RecordingServer) -> RecordingServer: recording_server.default_response = ResponseSpec(body=OCR_RESPONSE) @@ -122,61 +86,6 @@ async def test_response_replacement_finalized_before_dispatch_in_caller_task(ocr assert "response_cost" in response._hidden_params -@pytest.mark.asyncio -async def test_deployment_hook_replaces_complete_routing_request(ocr_server: RecordingServer) -> None: - ocr_server.enqueue(ResponseSpec(body=OCR_RESPONSE, delay=0.05)) - original: Final = {"type": "document_url", "document_url": "data:application/pdf;base64,YWJj"} - replacement: Final = {"type": "document_url", "document_url": "data:application/pdf;base64,YWJj"} - observed: Final = [] - - class Replace(CustomLogger): - async def async_pre_call_deployment_hook(self, kwargs, call_type): - return { - **kwargs, - "model": "azure_ai/mistral-ocr-latest", - "custom_llm_provider": "azure_ai", - "document": replacement, - "api_key": "replacement-key", - "api_base": ocr_server.base_url, - "extra_headers": {"x-deployment": "replacement"}, - "timeout": 2, - "pages": [2], - } - - class Observe(Logging): - def pre_call(self, input, api_key, additional_args): - observed.append((additional_args["complete_input_dict"]["document"], api_key)) - - litellm.callbacks.append(Replace()) - logger: Final = Observe( - model="mistral-ocr-latest", - messages=[], - stream=False, - call_type="aocr", - start_time=datetime.datetime.now(), - litellm_call_id="deployment-routing", - function_id="deployment-routing", - ) - response: Final = await call_aocr( - ocr_server, - document=original, - timeout=0.001, - litellm_logging_obj=logger, - ) - - assert response.pages[0].markdown == "native OCR response" - assert observed == [(replacement, "replacement-key")] - assert observed[0][0] is replacement - assert replacement == original - assert replacement is not original - assert original == {"type": "document_url", "document_url": "data:application/pdf;base64,YWJj"} - assert ocr_server.requests[0].path == "/providers/mistral/azure/ocr" - assert ocr_server.requests[0].headers["authorization"] == "Bearer replacement-key" - assert ocr_server.requests[0].headers["x-deployment"] == "replacement" - assert ocr_server.requests[0].body["document"] == replacement - assert ocr_server.requests[0].body["pages"] == [2] - - @pytest.mark.asyncio @pytest.mark.parametrize("asynchronous", [False, True]) async def test_metadata_failure_dispatches_only_failure_and_releases_logger( @@ -249,7 +158,7 @@ async def test_mapped_failure_identity_and_deployment_snapshot(ocr_server: Recor @pytest.mark.asyncio -@pytest.mark.parametrize("phase", ["pre", "http", "post"]) +@pytest.mark.parametrize("phase", ["pre", "http"]) async def test_cancellation_cleans_up_in_caller_task_without_terminal_dispatch( ocr_server: RecordingServer, phase: str ) -> None: @@ -262,11 +171,6 @@ async def test_cancellation_cleans_up_in_caller_task_without_terminal_dispatch( entered.set() await asyncio.Event().wait() - async def async_post_call_success_deployment_hook(self, request_data, response, call_type): - if phase == "post": - entered.set() - await asyncio.Event().wait() - litellm.callbacks.append(Pause()) if phase == "http": ocr_server.enqueue(ResponseSpec(body=OCR_RESPONSE, delay=0.2)) @@ -323,78 +227,6 @@ async def test_deferred_logging_requires_release_and_runs_at_most_once( assert events[0].response is response -@pytest.mark.asyncio -@pytest.mark.parametrize("failure", [RuntimeError("native enqueue failed"), asyncio.CancelledError("cancelled")]) -async def test_deferred_release_handles_enqueue_failure_once_without_replay( - ocr_server: RecordingServer, monkeypatch: pytest.MonkeyPatch, failure: BaseException -) -> None: - import inspect - - from litellm.litellm_core_utils import logging_worker - - attempts: Final[list[Coroutine[object, object, object]]] = [] - diagnostics: Final = [] - - class FailingWorker: - def ensure_initialized_and_enqueue(self, coroutine: Coroutine[object, object, object]) -> None: - attempts.append(coroutine) - raise failure - - recorder: Final = RecordingLogger() - logger: Final = Logging( - model="mistral-ocr-latest", - messages=[], - stream=False, - call_type="aocr", - start_time=datetime.datetime.now(), - litellm_call_id="release-failure", - function_id="release-failure", - dynamic_async_success_callbacks=[recorder], - ) - logger._defer_async_logging = True - response: Final = await call_aocr(ocr_server, litellm_logging_obj=logger) - monkeypatch.setattr(logging_worker, "GLOBAL_LOGGING_WORKER", FailingWorker()) - monkeypatch.setattr(sys, "unraisablehook", lambda event: diagnostics.append(event.exc_value)) - - if isinstance(failure, asyncio.CancelledError): - with pytest.raises(asyncio.CancelledError, match="cancelled") as caught: - ProxyBaseLLMRequestProcessing._flush_deferred_async_logging(logger, False) - assert caught.value is failure - assert diagnostics == [] - else: - ProxyBaseLLMRequestProcessing._flush_deferred_async_logging(logger, False) - assert diagnostics == [failure] - ProxyBaseLLMRequestProcessing._flush_deferred_async_logging(logger, False) - - assert len(attempts) == 1 - assert inspect.getcoroutinestate(attempts[0]) == inspect.CORO_CLOSED - assert response.pages[0].markdown == "native OCR response" - assert len(ocr_server.requests) == 1 - assert not any("success" in name or "failure" in name for name in recorder.names) - - -@pytest.mark.asyncio -async def test_abandoned_deferred_logging_is_collectable(ocr_server: RecordingServer) -> None: - async def invoke(): - logger: Final = Logging( - model="mistral-ocr-latest", - messages=[], - stream=False, - call_type="aocr", - start_time=datetime.datetime.now(), - litellm_call_id="abandoned", - function_id="abandoned", - ) - logger._defer_async_logging = True - await call_aocr(ocr_server, litellm_logging_obj=logger) - return weakref.ref(logger) - - reference: Final = await invoke() - await drain_logging() - gc.collect() - assert reference() is None - - def test_sync_success_uses_executor_and_copied_caller_context(ocr_server: RecordingServer) -> None: context: Final = ContextVar("sync-lifecycle", default="missing") context.set("caller") @@ -414,81 +246,6 @@ def test_sync_success_uses_executor_and_copied_caller_context(ocr_server: Record assert observations[0][2] is response -@pytest.mark.asyncio -@pytest.mark.parametrize("asynchronous", [False, True]) -async def test_invalid_response_runs_post_call_before_failure(ocr_server: RecordingServer, asynchronous: bool) -> None: - ocr_server.enqueue(ResponseSpec(body={"pages": "invalid"})) - events: Final = [] - - class Observe(Logging): - def pre_call(self, *args, **kwargs): - events.append("pre") - return super().pre_call(*args, **kwargs) - - def post_call(self, *args, **kwargs): - events.append(("post", kwargs["original_response"])) - return super().post_call(*args, **kwargs) - - def success_handler(self, *args, **kwargs): - events.append("success") - - def failure_handler(self, exception, *args, **kwargs): - events.append(("failure", exception)) - - async def async_failure_handler(self, exception, *args, **kwargs): - events.append(("async_failure", exception)) - - logger: Final = Observe( - model="mistral-ocr-latest", - messages=[], - stream=False, - call_type="aocr" if asynchronous else "ocr", - start_time=datetime.datetime.now(), - litellm_call_id="invalid", - function_id="invalid", - ) - with pytest.raises(litellm.APIConnectionError) as caught: - await call_aocr(ocr_server, litellm_logging_obj=logger) if asynchronous else call_ocr( - ocr_server, litellm_logging_obj=logger - ) - assert events[0] == "pre" - assert events[1] == ("post", '{"pages": "invalid"}') - assert events[2] == ("failure", caught.value) - if asynchronous: - assert events[3] == ("async_failure", caught.value) - assert "success" not in events - - -@pytest.mark.asyncio -async def test_failing_terminal_handler_preserves_public_failure_and_runs_async_handler( - ocr_server: RecordingServer, -) -> None: - ocr_server.enqueue(ResponseSpec(body={"message": "provider failure"}, status=500)) - failures: Final = [] - - class BrokenHandler(Logging): - def failure_handler(self, exception, *args, **kwargs): - failures.append(exception) - raise RuntimeError("handler failed") - - async def async_failure_handler(self, exception, *args, **kwargs): - failures.append(exception) - - logger: Final = BrokenHandler( - model="mistral-ocr-latest", - messages=[], - stream=False, - call_type="aocr", - start_time=datetime.datetime.now(), - litellm_call_id="broken", - function_id="broken", - ) - with pytest.raises(litellm.InternalServerError) as caught: - await call_aocr(ocr_server, litellm_logging_obj=logger) - assert failures == [caught.value, caught.value] - assert len(ocr_server.requests) == 1 - - @pytest.mark.asyncio async def test_nested_native_calls_preserve_context_and_dispatch_each_outcome(ocr_server: RecordingServer) -> None: ocr_server.expected_requests = 2 @@ -523,62 +280,6 @@ def test_sync_pre_call_can_make_nested_native_request(ocr_server: RecordingServe assert len(ocr_server.requests) == 2 -@pytest.mark.asyncio -async def test_retained_argument_aliases_and_body_roots_survive_envelope_replacement( - ocr_server: RecordingServer, -) -> None: - pages: Final = [0] - document: Final = {"type": "document_url", "document_url": "data:application/pdf;base64,YWJj"} - opaque: Final = object() - observed: Final = [] - - class Observe(Logging): - def pre_call(self, input, api_key, additional_args): - body: Final = additional_args["complete_input_dict"] - headers: Final = additional_args["headers"] - observed.append((body["document"] is document, body["pages"] is pages)) - pages.append(2) - headers["x-retained"] = "yes" - additional_args["complete_input_dict"] = {"discarded": True} - additional_args["headers"] = {} - observed.append((body, headers)) - - def post_call(self, original_response, additional_args): - observed.append( - (additional_args["complete_input_dict"] is observed[2][0], additional_args["headers"] is observed[2][1]) - ) - - class Deployment(CustomLogger): - async def async_pre_call_deployment_hook(self, kwargs, call_type): - observed.append(("model" in kwargs, "document" in kwargs, kwargs["opaque"] is opaque)) - - litellm.callbacks.append(Deployment()) - logger: Final = Observe( - model="mistral-ocr-latest", - messages=[], - stream=False, - call_type="aocr", - start_time=datetime.datetime.now(), - litellm_call_id="roots", - function_id="roots", - ) - response: Final = await litellm.aocr( - "mistral/mistral-ocr-latest", - document, - api_key="test-key", - api_base=ocr_server.base_url, - pages=pages, - opaque=opaque, - litellm_logging_obj=logger, - ) - assert response.pages[0].markdown == "native OCR response" - assert observed[0] == (False, False, True) - assert observed[1] == (True, True) - assert observed[3] == (True, True) - assert ocr_server.requests[0].body["pages"] == [0, 2] - assert ocr_server.requests[0].headers["x-retained"] == "yes" - - def test_unstarted_native_coroutine_releases_input_without_reading_file(ocr_server: RecordingServer) -> None: from litellm.ocr.dispatch import _public_request from litellm.rust_bridge import _native @@ -690,161 +391,18 @@ async def test_cancelling_native_transport_closes_connection_before_return() -> @pytest.mark.asyncio -@pytest.mark.parametrize("model", ["reducto/parse-v3", "reducto/parse-legacy"]) -async def test_reducto_lifecycle_retains_upload_parse_and_post_call_boundaries( - ocr_server: RecordingServer, model: str -) -> None: - ocr_server.expected_requests = 2 - ocr_server.enqueue(ResponseSpec(body={"file_id": "reducto://uploaded.pdf"})) - ocr_server.enqueue(ResponseSpec(body={"result": {"chunks": [{"content": "parsed"}]}})) - boundaries: Final = [] - recorder: Final = RecordingLogger() - - class Observe(Logging): - def post_call(self, *args, **kwargs): - boundaries.append(tuple(request.path for request in ocr_server.requests)) - return super().post_call(*args, **kwargs) - - logger: Final = Observe( - model=model, - messages=[], - stream=False, - call_type="aocr", - start_time=datetime.datetime.now(), - litellm_call_id="upload", - function_id="upload", - dynamic_async_success_callbacks=[recorder], - ) - response: Final = await call_aocr(ocr_server, model=model, litellm_logging_obj=logger) - events: Final = await recorder.wait_for_async("async_log_success_event") - assert boundaries == [("/upload", "/parse")] - assert b"abc" in ocr_server.requests[0].raw_body - assert "multipart/form-data" in ocr_server.requests[0].headers["content-type"] - assert ocr_server.requests[1].body["input" if model.endswith("v3") else "document_url"] == "reducto://uploaded.pdf" - assert response.pages[0].markdown == "parsed" - assert events[0].response is response - - -@pytest.mark.asyncio -async def test_document_intelligence_post_call_observes_submission_and_final_result( - ocr_server: RecordingServer, -) -> None: - ocr_server.expected_requests = 2 - ocr_server.enqueue( - ResponseSpec( - body={"status": "running"}, - status=202, - headers={"Operation-Location": f"{ocr_server.base_url}/operations/1", "Retry-After": "0"}, - ) - ) - ocr_server.enqueue(ResponseSpec(body={"status": "succeeded", "analyzeResult": {"pages": []}})) - boundaries: Final = [] - - class Observe(Logging): - def post_call(self, *args, **kwargs): - boundaries.append((tuple(request.method for request in ocr_server.requests), kwargs["original_response"])) - return super().post_call(*args, **kwargs) - - logger: Final = Observe( - model="azure_ai/doc-intelligence/prebuilt-read", - messages=[], - stream=False, - call_type="aocr", - start_time=datetime.datetime.now(), - litellm_call_id="poll", - function_id="poll", - ) - response: Final = await call_aocr( - ocr_server, model="azure_ai/doc-intelligence/prebuilt-read", litellm_logging_obj=logger - ) - assert [methods for methods, _ in boundaries] == [("POST",), ("POST", "GET")] - assert json.loads(boundaries[0][1])["status"] == "running" - assert json.loads(boundaries[1][1])["status"] == "succeeded" - assert [request.method for request in ocr_server.requests] == ["POST", "GET"] - assert ocr_server.requests[1].path == "/operations/1" - assert response.pages == [] - - -@pytest.mark.asyncio -async def test_vertex_deepseek_public_lifecycle_normalizes_before_success(ocr_server: RecordingServer) -> None: - ocr_server.enqueue( - ResponseSpec(body={"choices": [{"message": {"content": "recognized"}}], "usage": {"prompt_tokens": 1}}) - ) - recorder: Final = RecordingLogger() - response: Final = await call_aocr( - ocr_server, - model="vertex_ai/deepseek-ocr-maas", - document={"type": "document_url", "document_url": "gs://bucket/document.pdf"}, - vertex_project="project-1", - vertex_location="europe-west4", - callbacks=[recorder], - ) - events: Final = await recorder.wait_for_async("async_log_success_event") - assert response.pages[0].markdown == "recognized" - assert events[0].response is response - assert ( - ocr_server.requests[0].path - == "/v1/projects/project-1/locations/europe-west4/endpoints/openapi/chat/completions" - ) - - -@pytest.mark.asyncio -@pytest.mark.parametrize("asynchronous", [False, True]) -@pytest.mark.parametrize("limit", ["budget", "retries"]) -async def test_shared_call_limits_still_reject_before_reading_ocr_file( - ocr_server: RecordingServer, monkeypatch: pytest.MonkeyPatch, asynchronous: bool, limit: str -) -> None: - ocr_server.expected_requests = 0 - reads: Final = [] - - class File: - def read(self): - reads.append("read") - return b"abc" - - monkeypatch.setattr(litellm, "max_budget", 1 if limit == "budget" else None) - monkeypatch.setattr(litellm, "_current_cost", 2) - monkeypatch.setattr(litellm, "num_retries_per_request", 1 if limit == "retries" else None) - expected: Final = litellm.BudgetExceededError if limit == "budget" else RuntimeError - arguments: Final = {"document": {"type": "file", "file": File()}, "metadata": {"request_retry_count": 1}} - with pytest.raises(expected, match=r"Budget has been exceeded|Max retries per request hit"): - await call_aocr(ocr_server, **arguments) if asynchronous else call_ocr(ocr_server, **arguments) - assert reads == [] - assert ocr_server.requests == [] - - -@pytest.mark.asyncio -@pytest.mark.parametrize("asynchronous", [False, True]) -@pytest.mark.parametrize("extra_bytes", [0, 1]) -async def test_response_limit_is_enforced_at_the_public_boundary( - ocr_server: RecordingServer, asynchronous: bool, extra_bytes: int -) -> None: - limit: Final = len(json.dumps(OCR_RESPONSE).encode()) - extra_bytes - if extra_bytes: - with pytest.raises(litellm.APIConnectionError, match="OCR response exceeds the size limit"): - await call_aocr(ocr_server, max_response_bytes=limit) if asynchronous else call_ocr( - ocr_server, max_response_bytes=limit - ) - else: - response: Final = ( - await call_aocr(ocr_server, max_response_bytes=limit) - if asynchronous - else call_ocr(ocr_server, max_response_bytes=limit) - ) - assert response.pages[0].markdown == "native OCR response" +async def test_response_limit_is_enforced_at_the_public_boundary(ocr_server: RecordingServer) -> None: + limit: Final = len(json.dumps(OCR_RESPONSE).encode()) - 1 + with pytest.raises(litellm.APIConnectionError, match="OCR response exceeds the size limit"): + await call_aocr(ocr_server, max_response_bytes=limit) assert len(ocr_server.requests) == 1 - body: Final = ocr_server.requests[0].body - assert isinstance(body, dict) - assert "max_response_bytes" not in body @pytest.mark.asyncio -@pytest.mark.parametrize("asynchronous", [False, True]) @pytest.mark.parametrize("failure", [False, True]) async def test_empty_callbacks_keep_bookkeeping_without_optional_dispatch( ocr_server: RecordingServer, monkeypatch: pytest.MonkeyPatch, - asynchronous: bool, failure: bool, created_loggers: list[Logging], ) -> None: @@ -881,11 +439,9 @@ async def test_empty_callbacks_keep_bookkeeping_without_optional_dispatch( arguments: Final = {"litellm_trace_id": "callback-free-call", "litellm_call_id": "callback-free-id"} if failure: with pytest.raises(litellm.InternalServerError): - await call_aocr(ocr_server, **arguments) if asynchronous else call_ocr(ocr_server, **arguments) + await call_aocr(ocr_server, **arguments) else: - response: Final = ( - await call_aocr(ocr_server, **arguments) if asynchronous else call_ocr(ocr_server, **arguments) - ) + response: Final = await call_aocr(ocr_server, **arguments) assert response.pages[0].markdown == "native OCR response" assert response._hidden_params["litellm_call_id"] == "callback-free-id" assert response._hidden_params["response_cost"] is not None @@ -981,30 +537,3 @@ async def test_explicit_logging_consumers_keep_request_and_response_payloads( assert details["raw_request_typed_dict"]["raw_request_body"]["model"] == "mistral-ocr-latest" if consumer == "logger_fn": assert [item["log_event_type"] for item in snapshots] == ["pre_api_call", "post_api_call"] - - -@pytest.mark.asyncio -async def test_registration_removed_before_deferred_release_skips_queue( - ocr_server: RecordingServer, monkeypatch: pytest.MonkeyPatch, created_loggers: list[Logging] -) -> None: - from litellm.litellm_core_utils import logging_worker - - class QueueProbe: - enqueues = 0 - - def ensure_initialized_and_enqueue(self, coroutine: Coroutine[object, object, object]) -> None: - self.enqueues += 1 - coroutine.close() - - observer: Final = RecordingLogger() - litellm._async_success_callback.append(observer) - await call_aocr(ocr_server) - logger: Final = created_loggers[0] - assert hasattr(logger, "_native_pending_logging") - litellm._async_success_callback.clear() - probe: Final = QueueProbe() - monkeypatch.setattr(logging_worker, "GLOBAL_LOGGING_WORKER", probe) - ProxyBaseLLMRequestProcessing._flush_deferred_async_logging(logger, False) - assert probe.enqueues == 0 - assert not observer.names - assert logger.model_call_details["response_cost"] is not None diff --git a/tests/test_litellm_rust/ocr/test_requests.py b/tests/test_litellm_rust/ocr/test_requests.py index e360401a435..5e9d2c78808 100644 --- a/tests/test_litellm_rust/ocr/test_requests.py +++ b/tests/test_litellm_rust/ocr/test_requests.py @@ -1,4 +1,7 @@ import json +from collections.abc import Callable +from dataclasses import dataclass +from io import BytesIO from pathlib import Path from typing import Final @@ -230,118 +233,6 @@ def assert_native_request(server: RecordingServer) -> None: assert not server.requests[0].headers.get("user-agent", "").startswith("python-httpx") -def test_native_ocr_sends_model_and_document_to_mistral_ocr_path(ocr_server: RecordingServer) -> None: - response: Final = call_native_ocr(ocr_server) - - assert response.pages[0].markdown == "native OCR response" - assert_native_request(ocr_server) - assert ocr_server.requests[0].path == "/v1/ocr" - assert ocr_server.requests[0].body == {"model": "mistral-ocr-latest", "document": OCR_DOCUMENT} - - -def test_native_ocr_prepares_file_document_like_python(ocr_server: RecordingServer) -> None: - response: Final = call_native_ocr( - ocr_server, - document={"type": "file", "file": b"%PDF-1.4", "mime_type": "application/pdf"}, - ) - - assert response.pages[0].markdown == "native OCR response" - assert_native_request(ocr_server) - assert ocr_server.requests[0].body == { - "model": "mistral-ocr-latest", - "document": { - "type": "document_url", - "document_url": "data:application/pdf;base64,JVBERi0xLjQ=", - }, - } - - -def test_native_ocr_reads_sdk_path_input(ocr_server: RecordingServer, tmp_path: Path) -> None: - document_path: Final = tmp_path / "document.pdf" - document_path.write_bytes(b"%PDF-1.4") - - response: Final = call_native_ocr( - ocr_server, - document={"type": "file", "file": document_path}, - ) - - assert response.pages[0].markdown == "native OCR response" - assert ocr_server.requests[0].body["document"] == { - "type": "document_url", - "document_url": "data:application/pdf;base64,JVBERi0xLjQ=", - } - - -def test_native_ocr_sends_pages_and_image_options(ocr_server: RecordingServer) -> None: - call_native_ocr(ocr_server, pages=[0, 2], include_image_base64=True) - - assert ocr_server.requests[0].body["pages"] == [0, 2] - assert ocr_server.requests[0].body["include_image_base64"] is True - - -def test_native_ocr_merges_custom_headers_with_authorization(ocr_server: RecordingServer) -> None: - call_native_ocr(ocr_server, extra_headers={"x-trace-id": "trace-1"}) - - assert ocr_server.requests[0].headers["authorization"] == "Bearer test-key" - assert ocr_server.requests[0].headers["x-trace-id"] == "trace-1" - - -def test_native_mistral_ocr_uses_environment_api_key_when_argument_is_missing( - ocr_server: RecordingServer, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.setenv("MISTRAL_API_KEY", "environment-key") - - call_native_ocr(ocr_server, api_key=None) - - assert ocr_server.requests[0].headers["authorization"] == "Bearer environment-key" - - -def test_native_mistral_ocr_prefers_explicit_api_key_over_environment( - ocr_server: RecordingServer, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.setenv("MISTRAL_API_KEY", "environment-key") - - call_native_ocr(ocr_server) - - assert ocr_server.requests[0].headers["authorization"] == "Bearer test-key" - - -def test_native_azure_ocr_uses_environment_endpoint_and_api_key( - ocr_server: RecordingServer, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.setenv("AZURE_AI_API_KEY", "azure-key") - monkeypatch.setenv("AZURE_AI_API_BASE", ocr_server.base_url) - - call_native_ocr(ocr_server, model="azure_ai/pixtral-12b-2409", api_key=None, api_base=None) - - assert_native_request(ocr_server) - assert ocr_server.requests[0].path == "/providers/mistral/azure/ocr" - assert ocr_server.requests[0].headers["authorization"] == "Bearer azure-key" - - -def test_native_vertex_ocr_builds_path_from_project_and_location(ocr_server: RecordingServer) -> None: - call_native_ocr( - ocr_server, - model="vertex_ai/mistral-ocr-2505", - api_key="vertex-token", - vertex_project="project-1", - vertex_location="us-central1", - ) - - assert_native_request(ocr_server) - assert ocr_server.requests[0].path == ( - "/v1/projects/project-1/locations/us-central1/publishers/mistralai/models/mistral-ocr-2505:rawPredict" - ) - - -def test_native_ocr_normalizes_provider_response_model_and_usage(ocr_server: RecordingServer) -> None: - response: Final = call_native_ocr(ocr_server) - - assert isinstance(response, OCRResponse) - assert response.model == "mistral-ocr-latest" - assert response.usage_info.pages_processed == 1 - - def test_native_ocr_maps_provider_400_with_public_provider_details(ocr_server: RecordingServer) -> None: ocr_server.enqueue(ResponseSpec(body={"message": "invalid OCR request"}, status=400)) @@ -354,109 +245,13 @@ def test_native_ocr_maps_provider_400_with_public_provider_details(ocr_server: R assert "invalid OCR request" in str(caught.value) -def test_native_ocr_rejects_unknown_response_format_before_provider_request(ocr_server: RecordingServer) -> None: - ocr_server.expected_requests = 0 - - with pytest.raises(litellm.BadRequestError, match="Invalid `req_format`"): - call_native_ocr(ocr_server, req_format="raw") - - assert ocr_server.requests == [] - - -def test_ocr_raises_public_timeout_when_request_exceeds_timeout(ocr_server: RecordingServer) -> None: - litellm.rust(True) - ocr_server.enqueue(ResponseSpec(body=OCR_RESPONSE, delay=0.2)) - - with pytest.raises(litellm.Timeout): - call_native_ocr(ocr_server, timeout=0.01) - - assert len(ocr_server.requests) == 1 - - -@pytest.mark.asyncio -@pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"]) -@pytest.mark.parametrize( - "credentials, expected_token, expected_calls", - [ - ({"api_key": "resource-key"}, "resource-key", 0), - ({"azure_ad_token": "static-token"}, "callback-1", 1), - ({"extra_headers": {"Authorization": "Bearer override"}}, "override", 1), - ], - ids=["api-key-skips-provider", "provider-overrides-static-token", "header-overrides-provider"], -) -async def test_native_azure_ocr_applies_python_credential_precedence( - ocr_server: RecordingServer, - isolated_azure_auth: None, - asynchronous: bool, - credentials: dict[str, object], - expected_token: str, - expected_calls: int, -) -> None: - calls: Final = [] - - def token_provider() -> str: - calls.append("token") - return f"callback-{len(calls)}" - - arguments: Final = { - "model": "azure_ai/mistral-ocr-latest", - "api_key": None, - "azure_ad_token_provider": token_provider, - **credentials, - } - response: Final = ( - await call_native_aocr(ocr_server, **arguments) if asynchronous else call_native_ocr(ocr_server, **arguments) - ) - assert response.pages[0].markdown == "native OCR response" - assert len(calls) == expected_calls - assert len(ocr_server.requests) == 1 - assert ocr_server.requests[0].headers["authorization"] == f"Bearer {expected_token}" - - -@pytest.mark.asyncio -@pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"]) -async def test_native_azure_ocr_calls_token_provider_for_each_request( - ocr_server: RecordingServer, - isolated_azure_auth: None, - asynchronous: bool, -) -> None: - calls: Final = [] - ocr_server.expected_requests = 2 - - def token_provider() -> str: - calls.append("token") - return f"callback-{len(calls)}" - - for _ in range(2): - arguments: Final = { - "model": "azure_ai/mistral-ocr-latest", - "api_key": None, - "azure_ad_token_provider": token_provider, - } - response: Final = ( - await call_native_aocr(ocr_server, **arguments) - if asynchronous - else call_native_ocr(ocr_server, **arguments) - ) - assert response.pages[0].markdown == "native OCR response" - assert len(calls) == 2 - assert [request.headers["authorization"] for request in ocr_server.requests] == [ - "Bearer callback-1", - "Bearer callback-2", - ] - - class TokenAbort(BaseException): pass @pytest.mark.asyncio @pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"]) -@pytest.mark.parametrize( - "failure", - ["non_string", "type_error", "ordinary", "abort"], - ids=["non-string-result", "type-error", "value-error", "base-exception"], -) +@pytest.mark.parametrize("failure", ["ordinary", "abort"], ids=["value-error", "base-exception"]) async def test_native_azure_ocr_token_provider_failure_prevents_pre_call_callback_and_request( ocr_server: RecordingServer, isolated_azure_auth: None, @@ -466,16 +261,10 @@ async def test_native_azure_ocr_token_provider_failure_prevents_pre_call_callbac ocr_server.expected_requests = 0 calls: Final = [] recorder: Final = RecordingLogger() - original: Final = { - "type_error": TypeError("token type"), - "ordinary": ValueError("token unavailable"), - "abort": TokenAbort("abort"), - } + original: Final = {"ordinary": ValueError("token unavailable"), "abort": TokenAbort("abort")} def token_provider() -> object: calls.append("token") - if failure == "non_string": - return 123 raise original[failure] arguments: Final = { @@ -494,144 +283,8 @@ async def test_native_azure_ocr_token_provider_failure_prevents_pre_call_callbac assert "Failed to get Azure AD token: token unavailable" in str(caught.value) assert isinstance(caught.value.__context__, RuntimeError) assert caught.value.__context__.__cause__ is original[failure] - elif failure == "abort": - assert caught.value is original[failure] - elif failure == "type_error": - assert caught.value.__context__ is original[failure] else: - assert isinstance(caught.value.__context__, TypeError) - - -@pytest.mark.parametrize( - "configuration", - [{"azure_ad_token": "oidc/assertion", "client_id": "client", "tenant_id": "tenant"}], - ids=["invalid-oidc-assertion"], -) -def test_public_azure_ocr_maps_invalid_oidc_configuration_before_token_or_request( - ocr_server: RecordingServer, - isolated_azure_auth: None, - configuration: dict[str, object], -) -> None: - ocr_server.expected_requests = 0 - calls: Final = [] - recorder: Final = RecordingLogger() - - def provider() -> str: - calls.append("token") - return "unused" - - arguments: Final = { - "model": "azure_ai/mistral-ocr-latest", - "api_key": None, - "azure_ad_token_provider": provider, - "callbacks": [recorder], - **configuration, - } - with pytest.raises(litellm.APIConnectionError): - call_native_ocr(ocr_server, **arguments) - assert calls == [] - assert "log_pre_api_call" not in recorder.names - assert ocr_server.requests == [] - - -@pytest.mark.asyncio -async def test_native_azure_ocr_validates_endpoint_before_calling_token_provider( - ocr_server: RecordingServer, - isolated_azure_auth: None, -) -> None: - ocr_server.expected_requests = 0 - calls: Final = [] - - def provider() -> str: - calls.append("token") - return "unused" - - with pytest.raises(litellm.APIConnectionError, match="Missing Azure AI API Base"): - await call_native_aocr( - ocr_server, - model="azure_ai/mistral-ocr-latest", - api_key=None, - api_base=None, - azure_ad_token_provider=provider, - ) - assert calls == [] - assert ocr_server.requests == [] - - -@pytest.mark.asyncio -async def test_native_azure_ocr_does_not_fall_back_to_static_token_after_empty_provider_result( - ocr_server: RecordingServer, - isolated_azure_auth: None, -) -> None: - ocr_server.expected_requests = 0 - - def provider() -> str: - return "" - - with pytest.raises(litellm.APIConnectionError, match="Missing Azure AI credentials"): - await call_native_aocr( - ocr_server, - model="azure_ai/mistral-ocr-latest", - api_key=None, - azure_ad_token="static-token", - azure_ad_token_provider=provider, - ) - assert ocr_server.requests == [] - - -@pytest.mark.asyncio -async def test_native_azure_ocr_ignores_falsey_token_provider_and_uses_static_token( - ocr_server: RecordingServer, - isolated_azure_auth: None, -) -> None: - calls: Final = [] - - class Provider: - def __bool__(self) -> bool: - return False - - def __call__(self) -> str: - calls.append("token") - return "unused" - - response: Final = await call_native_aocr( - ocr_server, - model="azure_ai/mistral-ocr-latest", - api_key=None, - azure_ad_token="static-token", - azure_ad_token_provider=Provider(), - ) - assert response.pages[0].markdown == "native OCR response" - assert calls == [] - assert ocr_server.requests[0].headers["authorization"] == "Bearer static-token" - - -@pytest.mark.asyncio -async def test_native_azure_ocr_rejects_coroutine_returned_by_sync_token_provider( - ocr_server: RecordingServer, - isolated_azure_auth: None, -) -> None: - ocr_server.expected_requests = 0 - calls: Final = [] - - async def acquire() -> str: - calls.append("awaited") - return "unused" - - coroutine: Final = acquire() - - def provider() -> object: - return coroutine - - try: - with pytest.raises(litellm.APIConnectionError, match="Azure AD token must be a string"): - await call_native_aocr( - ocr_server, model="azure_ai/mistral-ocr-latest", api_key=None, azure_ad_token_provider=provider - ) - finally: - coroutine.close() - assert calls == [] - assert ocr_server.requests == [] + assert caught.value is original[failure] @pytest.mark.asyncio @@ -694,50 +347,6 @@ async def test_native_ocr_inherits_named_credentials_without_overwriting_argumen assert ocr_server.requests[0].body["pages"] == [0, 2] -@pytest.mark.parametrize( - "filename,field,mime", - [("scan.PNG", "image_url", "image/png"), ("document.pdf", "document_url", "application/pdf")], -) -def test_native_ocr_infers_mime_type_from_reader_name( - ocr_server: RecordingServer, filename: str, field: str, mime: str -) -> None: - from io import BytesIO - - file: Final = BytesIO(b"abc") - file.name = filename - call_native_ocr(ocr_server, document={"type": "file", "file": file}) - assert ocr_server.requests[0].body["document"] == {"type": field, field: f"data:{mime};base64,YWJj"} - - -def test_native_ocr_encodes_str_reader_results_as_utf8(ocr_server: RecordingServer) -> None: - from io import StringIO - - call_native_ocr(ocr_server, document={"type": "file", "file": StringIO("abc"), "mime_type": "text/plain"}) - assert ocr_server.requests[0].body["document"] == { - "type": "document_url", - "document_url": "data:text/plain;base64,YWJj", - } - - -@pytest.mark.parametrize("attribute", ["read", "name"]) -def test_native_file_preparation_preserves_property_errors(ocr_server: RecordingServer, attribute: str) -> None: - ocr_server.expected_requests = 0 - failure: Final = LookupError("file property failed") - - class File: - def __getattribute__(self, name: str): - if name == attribute: - raise failure - return super().__getattribute__(name) - - def read(self): - return b"abc" - - with pytest.raises(litellm.APIConnectionError, match="file property failed") as caught: - call_native_ocr(ocr_server, document={"type": "file", "file": File()}) - assert caught.value.__context__ is failure - - @pytest.mark.asyncio @pytest.mark.parametrize("asynchronous", [False, True]) async def test_native_file_preparation_preserves_reader_exception( @@ -758,51 +367,139 @@ async def test_native_file_preparation_preserves_reader_exception( assert caught.value.__context__ is failure -def test_native_file_preparation_rejects_unsupported_reader_results(ocr_server: RecordingServer) -> None: - ocr_server.expected_requests = 0 - - class Reader: - def read(self) -> int: - return 1 - - with pytest.raises(litellm.APIConnectionError, match="bytes or str") as caught: - call_native_ocr(ocr_server, document={"type": "file", "file": Reader()}) - assert isinstance(caught.value.__context__, TypeError) +COHERE_IMAGE: Final = {"type": "image_url", "image_url": "data:image/png;base64,YWJj"} +FILE_SIZE_LIMIT: Final = 50 * 1024 * 1024 -@pytest.mark.parametrize("kind", ["bytes", "path", "reader"]) -def test_native_file_preparation_rejects_oversized_input( - ocr_server: RecordingServer, kind: str, tmp_path: Path -) -> None: - ocr_server.expected_requests = 0 - limit: Final = 50 * 1024 * 1024 +class IntReader: + def read(self) -> int: + return 1 + + +def oversized_file(tmp_path: Path) -> Path: path: Final = tmp_path / "large.pdf" with path.open("wb") as stream: - stream.truncate(limit + 1) - - class Reader: - def read(self) -> bytes: - return b"a" * (limit + 1) - - document: Final = { - "type": "file", - "file": path if kind == "path" else Reader() if kind == "reader" else b"a" * (limit + 1), - } - with pytest.raises(litellm.BadRequestError, match="exceeds the size limit"): - call_native_ocr(ocr_server, document=document) + stream.truncate(FILE_SIZE_LIMIT + 1) + return path -def test_native_file_preparation_reports_missing_paths(ocr_server: RecordingServer, tmp_path: Path) -> None: - ocr_server.expected_requests = 0 - missing: Final = tmp_path / "missing.pdf" - with pytest.raises(litellm.APIConnectionError, match=f"File not found: {missing}") as caught: - call_native_ocr(ocr_server, document={"type": "file", "file": missing}) - assert isinstance(caught.value.__context__, FileNotFoundError) +def empty_token() -> str: + return "" -def test_native_file_preparation_rejects_empty_readers(ocr_server: RecordingServer) -> None: - from io import BytesIO +def unused_token() -> str: + raise AssertionError("the token provider must not run") - ocr_server.expected_requests = 0 - with pytest.raises(litellm.BadRequestError, match="File is empty"): - call_native_ocr(ocr_server, document={"type": "file", "file": BytesIO(b"")}) + +@dataclass(frozen=True, slots=True) +class PublicFailure: + arguments: Callable[[Path], dict[str, object]] + error: type[Exception] + match: str + provider_requests: int = 0 + response: ResponseSpec | None = None + cause: type[BaseException] | None = None + + +PUBLIC_FAILURES: Final = { + "unknown-req-format": PublicFailure( + lambda _: {"req_format": "raw"}, litellm.BadRequestError, "Invalid `req_format`" + ), + "empty-file": PublicFailure( + lambda _: {"document": {"type": "file", "file": BytesIO(b"")}}, litellm.BadRequestError, "File is empty" + ), + "oversized-file": PublicFailure( + lambda tmp_path: {"document": {"type": "file", "file": oversized_file(tmp_path)}}, + litellm.BadRequestError, + "exceeds the size limit", + ), + "missing-file": PublicFailure( + lambda tmp_path: {"document": {"type": "file", "file": tmp_path / "missing.pdf"}}, + litellm.APIConnectionError, + "File not found", + cause=FileNotFoundError, + ), + "reader-returns-non-bytes": PublicFailure( + lambda _: {"document": {"type": "file", "file": IntReader()}}, + litellm.APIConnectionError, + "bytes or str", + cause=TypeError, + ), + "cohere-non-image": PublicFailure( + lambda _: {"model": "cohere/parse-v5.0"}, litellm.BadRequestError, "only accepts `image_url`" + ), + "cohere-unknown-format": PublicFailure( + lambda _: {"model": "cohere/parse-v5.0", "document": COHERE_IMAGE, "output_format": "html"}, + litellm.BadRequestError, + "output_format", + ), + "azure-missing-api-base": PublicFailure( + lambda _: { + "model": "azure_ai/mistral-ocr-latest", + "api_key": None, + "api_base": None, + "azure_ad_token_provider": unused_token, + }, + litellm.APIConnectionError, + "Missing Azure AI API Base", + ), + "azure-empty-token": PublicFailure( + lambda _: { + "model": "azure_ai/mistral-ocr-latest", + "api_key": None, + "azure_ad_token": "static-token", + "azure_ad_token_provider": empty_token, + }, + litellm.APIConnectionError, + "Missing Azure AI credentials", + ), + "upstream-500": PublicFailure( + lambda _: {}, + litellm.InternalServerError, + "provider unavailable", + provider_requests=1, + response=ResponseSpec(body={"message": "provider unavailable"}, status=500), + ), + "invalid-provider-response": PublicFailure( + lambda _: {}, + litellm.APIConnectionError, + "pages", + provider_requests=1, + response=ResponseSpec(body={"pages": "invalid"}), + ), + "response-over-limit": PublicFailure( + lambda _: {"max_response_bytes": len(json.dumps(OCR_RESPONSE).encode()) - 1}, + litellm.APIConnectionError, + "OCR response exceeds the size limit", + provider_requests=1, + ), + "timeout": PublicFailure( + lambda _: {"timeout": 0.01}, + litellm.Timeout, + "", + provider_requests=1, + response=ResponseSpec(body=OCR_RESPONSE, delay=0.2), + ), +} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"]) +@pytest.mark.parametrize("failure", PUBLIC_FAILURES.values(), ids=PUBLIC_FAILURES.keys()) +async def test_native_failures_raise_the_public_exception_class( + ocr_server: RecordingServer, + isolated_azure_auth: None, + tmp_path: Path, + asynchronous: bool, + failure: PublicFailure, +) -> None: + ocr_server.expected_requests = failure.provider_requests + if failure.response is not None: + ocr_server.enqueue(failure.response) + + with pytest.raises(failure.error, match=failure.match) as caught: + await call_native(ocr_server, asynchronous, **failure.arguments(tmp_path)) + + assert len(ocr_server.requests) == failure.provider_requests + if failure.cause is not None: + assert isinstance(caught.value.__context__, failure.cause) diff --git a/tests/test_litellm_rust/test_ocr.py b/tests/test_litellm_rust/test_ocr.py index 8eccbea1a73..2fbf9817a53 100644 --- a/tests/test_litellm_rust/test_ocr.py +++ b/tests/test_litellm_rust/test_ocr.py @@ -70,104 +70,21 @@ def ocr_server() -> Generator[tuple[ThreadingHTTPServer, list[dict[str, object]] thread.join() -@pytest.mark.parametrize( - "file_input,mime_type,expected_type,expected_field,expected_uri", - [ - (b"abc", "application/pdf", "document_url", "document_url", "data:application/pdf;base64,YWJj"), - (BytesIO(b"abc"), "image/png", "image_url", "image_url", "data:image/png;base64,YWJj"), - ], -) -def test_native_lifecycle_core_encodes_python_file_input( - ocr_server, - file_input, - mime_type, - expected_type, - expected_field, - expected_uri, -): +def test_native_lifecycle_core_encodes_python_file_input(ocr_server): server, requests = ocr_server litellm.rust(True) response = litellm.ocr( model="mistral/mistral-ocr-latest", - document={"type": "file", "file": file_input, "mime_type": mime_type}, + document={"type": "file", "file": BytesIO(b"abc"), "mime_type": "image/png"}, api_key="test-key", api_base=f"http://127.0.0.1:{server.server_port}", opaque_extension=object(), ) assert response.pages[0].markdown == "native OCR response" - assert requests[0]["body"]["document"] == { - "type": expected_type, - expected_field: expected_uri, - } + assert requests[0]["body"]["document"] == {"type": "image_url", "image_url": "data:image/png;base64,YWJj"} assert "opaque_extension" not in requests[0]["body"] -@pytest.mark.parametrize("asynchronous", [False, True]) -@pytest.mark.parametrize("model", ["mistral/mistral-ocr-latest", "azure_ai/doc-intelligence/prebuilt-read"]) -@pytest.mark.asyncio -async def test_native_public_ocr_matches_python(model, asynchronous): - import json - from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer - from threading import Thread - from typing import Final - from urllib.parse import parse_qsl, urlsplit - - from litellm.rust_bridge import _native - - assert callable(_native.ocr) - calls: Final = [] - - class Handler(BaseHTTPRequestHandler): - def do_POST(self): - body: Final = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) - target: Final = urlsplit(self.path) - calls.append( - ( - target.path, - parse_qsl(target.query), - self.headers.get("Authorization"), - self.headers.get("Ocp-Apim-Subscription-Key"), - body, - ) - ) - payload: Final = ( - {"status": "succeeded", "analyzeResult": {"pages": []}} - if "doc-intelligence" in model - else {"pages": [{"index": 0, "markdown": "hello"}]} - ) - encoded: Final = json.dumps(payload).encode() - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(encoded))) - self.end_headers() - self.wfile.write(encoded) - - def log_message(self, *_args): - pass - - server: Final = ThreadingHTTPServer(("127.0.0.1", 0), Handler) - thread: Final = Thread(target=server.serve_forever, daemon=True) - thread.start() - try: - litellm.rust(True) - arguments: Final = { - "model": model, - "document": {"type": "document_url", "document_url": "data:application/pdf;base64,YWJj"}, - "api_key": "test-key", - "api_base": f"http://127.0.0.1:{server.server_port}", - "pages": [0, 2], - "timeout": 3.0, - } - response: Final = await litellm.aocr(**arguments) if asynchronous else litellm.ocr(**arguments) - response_data: Final = response.model_dump() - assert len(calls) == 1 - assert response_data["object"] == "ocr" - finally: - server.shutdown() - server.server_close() - thread.join(timeout=3) - - @pytest.mark.parametrize("asynchronous", [False, True]) @pytest.mark.asyncio async def test_native_ocr_failures_do_not_retry_on_python(ocr_server, asynchronous):