diff --git a/.gitignore b/.gitignore index deb0acae56e..7da917ce450 100644 --- a/.gitignore +++ b/.gitignore @@ -147,3 +147,6 @@ crash.*.log ui/litellm-dashboard/out/ litellm.log + +.coverage-rust +coverage-rust.xml diff --git a/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py b/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py index b2eda76f9ae..f40ced302ce 100644 --- a/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py +++ b/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py @@ -780,7 +780,10 @@ async def update_project( # Handle budget updates budget_fields = LiteLLM_BudgetTable.model_fields.keys() - budget_updates = {k: v for k, v in update_data.items() if k in budget_fields} + budget_updates = { + **{k: v for k, v in update_data.items() if k in budget_fields}, + **({"max_budget": None} if "max_budget" in data.model_fields_set and data.max_budget is None else {}), + } if budget_updates and existing_project.budget_id: # Update existing budget diff --git a/enterprise/pyproject.toml b/enterprise/pyproject.toml index 903c5155a12..c049bf68c46 100644 --- a/enterprise/pyproject.toml +++ b/enterprise/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "litellm-enterprise" -version = "0.1.66" +version = "0.1.67" description = "Package for LiteLLM Enterprise features" readme = "README.md" requires-python = ">=3.9" @@ -26,7 +26,7 @@ required-version = ">=0.10.9" module-root = "" [tool.commitizen] -version = "0.1.66" +version = "0.1.67" version_files = [ "pyproject.toml:^version", "../pyproject.toml:litellm-enterprise==", diff --git a/litellm-proxy-extras/litellm_proxy_extras/prisma_toolchain.py b/litellm-proxy-extras/litellm_proxy_extras/prisma_toolchain.py index b51de9609d3..9cd48fcf11a 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/prisma_toolchain.py +++ b/litellm-proxy-extras/litellm_proxy_extras/prisma_toolchain.py @@ -37,11 +37,13 @@ raised it above the deploy default keeps that larger budget for deploy unless the deploy override says otherwise. """ +import importlib.util import math import os import shutil import signal import subprocess +import sys from collections.abc import Mapping, Sequence from dataclasses import dataclass from pathlib import Path @@ -64,6 +66,7 @@ DEFAULT_PRISMA_BOOTSTRAP_TIMEOUT = 600.0 DEFAULT_PRISMA_MIGRATE_DEPLOY_TIMEOUT = 600.0 BOOTSTRAP_ARG = "--version" +PRISMA_CONSOLE_SCRIPT = "prisma" @dataclass(frozen=True) @@ -184,6 +187,28 @@ def _kill_process_group(process: "subprocess.Popen[str]") -> None: return +def prisma_cli_available() -> bool: + """Whether some way of running the Prisma CLI exists: the console script on PATH or the importable package.""" + if shutil.which(PRISMA_CONSOLE_SCRIPT) is not None: + return True + return importlib.util.find_spec(PRISMA_CONSOLE_SCRIPT) is not None + + +def resolve_prisma_argv(argv: Sequence[str]) -> tuple[str, ...]: + """Route a bare ``prisma`` command through ``python -m prisma`` when the console script is not on PATH. + + The console script and ``python -m prisma`` are the same entry point, but + only the module form survives an interpreter whose ``bin`` directory is + missing from PATH, which is how the proxy gets started under launchers and + init systems. Any other executable name is left untouched. + """ + if not argv or argv[0] != PRISMA_CONSOLE_SCRIPT: + return tuple(argv) + if shutil.which(PRISMA_CONSOLE_SCRIPT) is not None: + return tuple(argv) + return (sys.executable, "-m", PRISMA_CONSOLE_SCRIPT, *argv[1:]) + + def run_prisma( argv: Sequence[str], *, @@ -200,7 +225,7 @@ def run_prisma( text unless ``stdout``/``stderr`` say otherwise. """ with subprocess.Popen( - argv, + resolve_prisma_argv(argv), env=env, stdout=stdout, stderr=stderr, diff --git a/litellm-proxy-extras/pyproject.toml b/litellm-proxy-extras/pyproject.toml index 7d4c78088f1..f94591872a4 100644 --- a/litellm-proxy-extras/pyproject.toml +++ b/litellm-proxy-extras/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "litellm-proxy-extras" -version = "0.4.96" +version = "0.4.97" description = "Additional files for the LiteLLM Proxy. Reduces the size of the main litellm package." readme = "README.md" requires-python = ">=3.9" @@ -26,7 +26,7 @@ required-version = ">=0.10.9" module-root = "" [tool.commitizen] -version = "0.4.96" +version = "0.4.97" version_files = [ "pyproject.toml:^version", "../pyproject.toml:litellm-proxy-extras==", diff --git a/litellm-rust/ADDING_A_PROVIDER.md b/litellm-rust/ADDING_A_PROVIDER.md deleted file mode 100644 index ae8ae5a6870..00000000000 --- a/litellm-rust/ADDING_A_PROVIDER.md +++ /dev/null @@ -1,29 +0,0 @@ -# Adding a provider / route to litellm-rust - -Everything for a route lives in `crates/core/src//`; `crates/core/src/messages` is the reference. A host (the axum gateway, the Python bridge) only calls the route's entrypoint. - -1. **Entrypoint** — `mod.rs`: `pub async fn (request) -> CoreResult`, the Rust equivalent of `litellm.()`, plus a `_stream` variant when the route streams. It is the only thing a host touches. -2. **Transform contract** — `transformation.rs`: a `…ProviderConfig` trait (URL build + request/response transforms) with types in `types.rs`. -3. **Provider config** — `crates/core/src/providers///transformation.rs`: implement that trait as a `const __CONFIG`, mirroring the Python provider tree. Add parity unit tests. -4. **Prepare + handler** — `prepare.rs` resolves provider/model, credentials, auth headers, and URL, then transforms the request; `handler.rs` performs the provider call through the shared client in `client.rs` and transforms the response. - -## Coding standards - -Before writing new logic, look for an existing base to extend. When a change is -“the same behavior for one more provider/endpoint/integration”, the codebase -almost always already has a shared abstraction for it (for example, provider -`BaseConfig` transformation classes in `litellm/llms/base_llm/`, shared -helpers in `litellm_core_utils/`, typed request/response models, or factory -functions). Find it first with a search, then add the new variant by inheriting -from or composing that base, overriding only what genuinely differs (model -name, parameter mapping, or auth). - -Never copy an existing implementation and edit it in place, and never hand-roll -a parallel version of logic a base already provides. If you catch yourself -writing a second copy of a pattern that exists twice already, stop and extract a -base instead: put the shared shape in one place and make both call sites thin -variants of it. The test for a good abstraction is that adding the next provider -is a few declarative lines, not a new file of duplicated flow. Only diverge from -the base when behavior is genuinely different, and say so explicitly in the PR. - -**Calling:** hosts invoke the core entrypoint — the Python bridge and the `ai-gateway` route service both call `litellm_core::messages::messages`. Never add a provider handler to `ai-gateway`. Register new modules in `lib.rs` / `mod.rs`, then run the commands under "Checks" in [CLAUDE.md](CLAUDE.md). diff --git a/litellm-rust/AGENTS.md b/litellm-rust/AGENTS.md deleted file mode 100644 index 17856218e60..00000000000 --- a/litellm-rust/AGENTS.md +++ /dev/null @@ -1,45 +0,0 @@ -# AGENTS.md - -litellm-rust has six crates. A crate is a layer or shared foundation, not a route. Routes (ocr, realtime, chat) and providers (mistral, openai) are modules inside the layers. - -## Crates - -| Crate | Role | -|-------|------| -| litellm-core | The LiteLLM SDK in Rust. One public entrypoint per top-level call (`messages::messages()`), owning types, transforms, provider resolution, auth, and the provider HTTP call. Call it, get a typed response. | -| litellm-token-counter | Standalone input token counting shared by host integrations without pulling in the full SDK. | -| litellm-config | Config-loading boundary. Returns resolved core deployment data and optionally delegates loading to Python. | -| litellm-ai-gateway | The axum server (behind the `server` feature) plus the WebSocket hosts. Translates HTTP/WS to core entrypoints; owns no provider logic and no handlers. | -| litellm-python-interop | Domain-neutral PyO3 foundation for GIL handling and typed Python/Serde conversion. | -| litellm-python-bridge | PyO3 cdylib exposing LiteLLM Rust APIs to the Python SDK. Owns API registration, domain wiring, and Python exception mapping. | - -Dependency direction is acyclic: `litellm-config` depends on `litellm-core`, the gateway depends on both, and `litellm-python-bridge` depends on the domain layers, `litellm-token-counter`, and `litellm-python-interop`. The token counter and interop foundations depend on no LiteLLM domain crate. - -## Where a route lives - -A top-level LiteLLM call is a module under `crates/core/src//`, shaped like `messages`: - -``` -core/src/messages/ - mod.rs # pub async fn messages(..) -> CoreResult<..> (+ messages_stream for SSE) - types.rs # request/response types, MessagesRequest - transformation.rs # the provider template trait - prepare.rs # provider resolution, auth headers, URL - handler.rs # the provider call - client.rs # the shared reqwest client -``` - -Handlers never live in `ai-gateway`. `ocr`, `audio_transcription`, and `realtime` are still hosted there from before this rule; they move to `core` as they are touched. - -Adding a crate: default to a module. A new crate requires a real trigger: separate artifact (binary/cdylib), proc-macro, shared foundation, or publishable standalone. A new provider or route is none of these. - -Adding a crate fails crates/core/tests/workspace_crate_allowlist.rs until you update its allowlist and this file — intentional. - -## Style - -All Rust in `litellm-rust/` follows the official Rust Style Guide: -https://doc.rust-lang.org/style-guide/ - -`rustfmt` implements its formatting by default, so run `cargo fmt` before committing; CI gates every PR on `cargo fmt --check`. Do not hand-format against rustfmt or add a `rustfmt.toml` that diverges from the default style. - -Beyond formatting, follow the guide's naming and idiom conventions rustfmt cannot auto-apply: `snake_case` items/functions/modules, `UpperCamelCase` types/traits/variants, `SCREAMING_SNAKE_CASE` constants/statics (acronyms as one word, e.g. `HttpClient`), and the import grouping and item ordering it prescribes. See CLAUDE.md for the detailed version. diff --git a/litellm-rust/CLAUDE.md b/litellm-rust/CLAUDE.md deleted file mode 100644 index dfacf37b6cd..00000000000 --- a/litellm-rust/CLAUDE.md +++ /dev/null @@ -1,189 +0,0 @@ -# CLAUDE.md - -This file defines the rules for Rust work in LiteLLM. - -## Provider Coding Standards - -Before writing new logic, look for an existing base to extend. When a change is -“the same behavior for one more provider/endpoint/integration”, the codebase -almost always already has a shared abstraction for it (for example, provider -`BaseConfig` transformation classes in `litellm/llms/base_llm/`, shared -helpers in `litellm_core_utils/`, typed request/response models, or factory -functions). Find it first with a search, then add the new variant by inheriting -from or composing that base, overriding only what genuinely differs (model -name, parameter mapping, or auth). - -Never copy an existing implementation and edit it in place, and never hand-roll -a parallel version of logic a base already provides. If you catch yourself -writing a second copy of a pattern that exists twice already, stop and extract a -base instead: put the shared shape in one place and make both call sites thin -variants of it. The test for a good abstraction is that adding the next provider -is a few declarative lines, not a new file of duplicated flow. Only diverge from -the base when behavior is genuinely different, and say so explicitly in the PR. - -## Crates (see AGENTS.md) - -`litellm-core` **is** the LiteLLM SDK in Rust: it makes the LLM call. -`litellm-config` is the config-loading boundary and returns resolved core types. -`litellm-ai-gateway` is an HTTP/WebSocket server in front of it, and -`litellm-python-bridge` exposes it to the Python SDK. `litellm-python-interop` -holds domain-neutral PyO3 primitives shared by Python-facing Rust code. A crate -is a layer or shared foundation, not a route; add modules, not crates. - -## Core Boundary - -`litellm-core` owns the whole call. The Rust equivalent of `litellm.messages()` -is `litellm_core::messages::messages(request).await`: you call it, it does the -provider call, and you get a typed non-streaming response back. - -Route-level Rust structure mirrors LiteLLM's Python responsibilities: -- `core/src//` owns the route end to end: the public entrypoint fn named - after the route in `mod.rs`, the request/response types (`types.rs`), the - provider template trait (`transformation.rs`), the provider/auth/URL - resolution (`prepare.rs`), the HTTP client (`client.rs`), and the handler that - performs the call (`handler.rs`). `core/src/messages` is the reference. -- `core/src/providers///transformation.rs` owns the - provider-specific transform. For Anthropic Messages, this means - `core/src/providers/anthropic/messages/transformation.rs`. -- Handlers live in `core`, never in a host. `ai-gateway` must not contain a - route handler that talks to a provider; its axum route reads the HTTP request, - picks a deployment, and calls the `core` entrypoint. `python-bridge` marshals - Python objects and calls the same entrypoint. - -Streaming keeps the same shape: the route entrypoint has a `_stream` -variant in `core` that returns the upstream response so a host can splice it to -its own caller; the host still owns no provider logic. - -Call-hook and lifecycle instrumentation, including phase timing, usage -accumulation, and callback payload construction, always lives in `core`. -Hosts feed observed events into core and dispatch the completed payloads through -their I/O logger; hosts must not own callback orchestration. - -Allowed in `core`: -- The public entrypoint for a top-level LiteLLM call -- Request/response transforms and stream chunk normalization -- Provider resolution, auth header construction, and URL building -- The provider HTTP call itself, through a shared reused client with connect and - request timeouts -- Shared data types and validation errors -- Deterministic token/cost helper logic - -Not allowed in `core`: -- Serving HTTP: axum routes, extractors, and transport concerns stay in the host -- Filesystem access -- Database access -- Config file reading and rollout state -- Logging callbacks, spend writes, or custom callbacks -- Global mutable runtime state - -Env reads in `core` are limited to credential fallback inside a route's -`prepare.rs` (the `env_lookup` closure), mirroring what the Python SDK does when -no key is passed. Everything else config-shaped is resolved by the host and -passed in. - -Routes still hosted in `ai-gateway` (`ocr`, `audio_transcription`, `realtime`) -predate this rule and are being moved into `core` route modules; do not add new -ones there, and prefer moving one when you touch it. - -Python owns rollout state and fallback while Rust is being introduced. Rust -paths must be off by default until parity tests prove equivalence with Python. -A new provider/route may instead be implemented rust-only with no Python -reference; then the Python interface is a thin dispatch that calls Rust with no -fallback, and you state the rust-only choice explicitly in the PR. Either way -the Python side stays minimal (it only marshals inputs and calls the Rust -interface), never add a per-route feature flag, and never push provider -dispatch into `litellm/main.py`; put it in a thin dispatch class under -`litellm/llms///`. - -## Production Bar - -Rust code in this workspace is held to a strict parity and robustness bar from -the first PR: - -- Correctness parity is proven with tests. Do not rely on README claims or - manual inspection for a port that mirrors Python behavior. -- Every provider transform must have unit tests for supported-parameter - filtering, request body shape, response normalization, missing/null fields, - and bad-input errors. -- When Rust is exposed through Python, add Python tests that prove disabled, - enabled, and unavailable-bridge fallback behavior. -- Avoid panics on user/provider input. Return typed errors and let the host map - them to Python exceptions or HTTP responses. -- OCR handles documents that often contain personal data. Do not log document - contents, base64 payloads, provider response bodies, or secrets. -- Error messages must be useful but data-minimized. Truncate or sanitize any - upstream body before it crosses a host boundary. -- Treat empty or whitespace-only credentials, URLs, and config values as absent - at the host/config resolution layer. -- Preserve Python output shape intentionally. If a field is always serialized as - `null` for Python parity, leave a short comment explaining that parity choice. - -## Network I/O Rules - -These rules apply to every module that executes network I/O, whether it is a -`core` route handler or a host such as `ai-gateway`: - -- Set connect and full-request timeouts. No unbounded waits. -- Reuse HTTP clients; do not construct clients per request. -- Prefer rustls TLS for portable Python wheels and Linux images unless there is - a documented reason not to. -- Add request IDs and structured tracing at the host layer, without logging OCR - document contents or secrets. -- Do not echo raw upstream response bodies to callers. Sanitize and bound them. -- Avoid `expect`/`unwrap` in server startup and request paths unless the panic is - impossible by construction and documented. - -## Rust Style Guide - -All Rust in `litellm-rust/` follows the official Rust Style Guide: -https://doc.rust-lang.org/style-guide/ - -`rustfmt` implements the guide's formatting rules by default, so the mechanical -side is enforced for you: run `cargo fmt` before committing and CI gates every -PR on `cargo fmt --check` (see Checks). Do not hand-format against rustfmt or add -a `rustfmt.toml` that diverges from the default style; the default style *is* the -guide. - -The guide also covers conventions rustfmt cannot auto-apply; follow these too: -- Naming: `snake_case` for items, functions, and modules; `UpperCamelCase` for - types, traits, and enum variants; `SCREAMING_SNAKE_CASE` for constants and - statics; acronyms count as one word (`HttpClient`, not `HTTPClient`). -- Ordering and grouping the guide prescribes: imports grouped std / external / - crate-local, derives before other attributes, and consistent item order. -- Idioms the guide recommends over the formatter fighting you (e.g. prefer - restructuring an over-long expression rather than forcing an awkward wrap). - -## Constants - -Magic numbers and fixed strings go in a crate-level `constants.rs`, never -hardcoded inline — the Rust mirror of Python's `litellm/constants.py`. - -- Each crate that needs them has `src/constants.rs` (declared `mod constants;`); - import from it (`use crate::constants::...`). Don't scatter `const` values at - the top of feature modules. -- An env-overridable tunable still lives in `constants.rs` as its `DEFAULT_*` - value; the env read (with fallback to that default) happens at the host/config - resolution layer, not in `core`/`providers`. -- Exception: a value that is purely local to one function and has no meaning - elsewhere may stay inline, but prefer `constants.rs` when in doubt. - -## Checks - -Run these before pushing Rust changes. The same checks run in GitHub Actions -for changes under `litellm-rust/`. - -```bash -cd litellm-rust -cargo fmt --check -cargo clippy --workspace --all-targets -- -D warnings -cargo clippy -p litellm-core --all-targets --features bedrock-auth -- -D warnings -# the ai-gateway binary + server code is behind the `server` feature -cargo clippy -p litellm-ai-gateway --all-targets --all-features -- -D warnings -cargo test --workspace -cargo test -p litellm-core --features bedrock-auth -# the `auth`, `routes`, `state` and `realtime` tests only exist under `server` -cargo test -p litellm-ai-gateway --features server -``` - -When a Rust path is exposed through Python, add Python parity tests that compare -the existing Python output with the Rust-backed output. diff --git a/litellm-rust/Cargo.lock b/litellm-rust/Cargo.lock index 7b0b593b70f..7e3d25e9c5d 100644 --- a/litellm-rust/Cargo.lock +++ b/litellm-rust/Cargo.lock @@ -1948,12 +1948,17 @@ dependencies = [ "azure_core", "azure_identity", "base64 0.22.1", + "bytes", "data-url", + "futures-util", "gcp_auth", + "mime_guess", "moka", "rand 0.8.7", "reqwest 0.12.28", "rstest", + "rustls 0.23.42", + "rustls-native-certs", "serde", "serde_json", "serde_path_to_error", @@ -1962,6 +1967,7 @@ dependencies = [ "subtle", "thiserror 2.0.19", "tokio", + "tokio-tungstenite", "tracing", "tracing-subscriber", "url", @@ -1974,12 +1980,12 @@ version = "0.1.0" dependencies = [ "criterion", "futures-util", - "litellm-ai-gateway", "litellm-core", "litellm-python-interop", "litellm-token-counter", "pyo3", "pyo3-async-runtimes", + "rstest", "serde", "serde_json", "tokio", diff --git a/litellm-rust/Cargo.toml b/litellm-rust/Cargo.toml index 5f25e69a1f8..5c72c86d6ef 100644 --- a/litellm-rust/Cargo.toml +++ b/litellm-rust/Cargo.toml @@ -16,6 +16,7 @@ license = "MIT" repository = "https://github.com/BerriAI/litellm" [workspace.dependencies] +bytes = "1" tracing = "0.1" tracing-subscriber = { version = "0.3", default-features = false, features = ["registry", "std"] } litellm-core = { path = "crates/core" } diff --git a/litellm-rust/README.md b/litellm-rust/README.md deleted file mode 100644 index 650d38753e7..00000000000 --- a/litellm-rust/README.md +++ /dev/null @@ -1,56 +0,0 @@ -# LiteLLM Rust - -This workspace contains the staged Rust implementation for LiteLLM. - -`litellm-core` is the LiteLLM SDK in Rust: one entrypoint per top-level call -that makes the LLM call and hands back a typed response, the same shape as -`litellm.messages()` in Python. - -```rust -let response = litellm_core::messages::messages(MessagesRequest { - model: "claude-sonnet-4-5", - body, - api_key: Some(key), - .. -}) -.await?; -``` - -Python continues to own configuration, retries, routing policy, logging, -callbacks, spend tracking, and customer plugins until each Rust path has parity -coverage and production evidence. - -## Crates - -| Crate | Role | -|-------|------| -| litellm-core | The SDK. Per-route entrypoints (`messages::messages()`), types, provider transforms (modules under `providers/`), provider resolution, auth, the provider HTTP call, and the router. | -| litellm-config | Config-loading boundary. Returns resolved deployments and optionally delegates loading to Python. | -| litellm-ai-gateway | The axum server (behind the `server` feature) and WebSocket hosts. Translates HTTP/WS to core entrypoints; no provider handlers. | -| litellm-python-interop | Domain-neutral PyO3 foundation for GIL handling and typed Python/Serde conversion. | -| litellm-python-bridge | PyO3 cdylib exposing LiteLLM Rust APIs to the Python SDK. Owns API registration, domain wiring, and Python exception mapping. | - -Dependency direction is acyclic: config depends on core, the gateway depends on config and core, and the Python bridge depends on the domain layers and Python interop. - -## Layout - -```text -crates/ - core/ The SDK: route modules + provider transforms. - src/messages/ mod.rs (entrypoint), types, transformation, prepare, handler, client - src/providers/anthropic/messages/transformation.rs - config/ Config loading and resolved deployments. - ai-gateway/ Axum server + WebSocket hosts; calls core entrypoints. - python-interop/ Domain-neutral PyO3 conversion and GIL primitives. - python-bridge/ PyO3 API adapter for Python LiteLLM. -``` - -The folder shape follows the Python provider tree: -`core/src/providers///transformation.rs`. The bridge exposes one -function per top-level route, mirroring the core entrypoints. - -## Checks - -Run the commands under "Checks" in [CLAUDE.md](CLAUDE.md) before pushing Rust -changes. That list is the single source of truth and matches what GitHub Actions -runs for changes under `litellm-rust/`. diff --git a/litellm-rust/crates/CODING_STANDARDS/PROVIDER_CODING_STANDARDS.md b/litellm-rust/crates/CODING_STANDARDS/PROVIDER_CODING_STANDARDS.md deleted file mode 100644 index 952bbc38b43..00000000000 --- a/litellm-rust/crates/CODING_STANDARDS/PROVIDER_CODING_STANDARDS.md +++ /dev/null @@ -1,53 +0,0 @@ -# Provider coding standards (litellm-rust) - -Rules for adding or changing an LLM provider/route in `litellm-rust`. `messages` (`core/src/messages`, `ANTHROPIC_MESSAGES_CONFIG`) is the reference: a route is a `core` module with a public entrypoint that makes the call and returns a typed response. - -## Provider resolution - -1. Always resolve the provider/model first with `get_custom_llm_provider` (`core/src/routing_utils/provider.rs`). Nothing downstream may branch on a raw model string. -2. Model/provider is resolved once, in `prepare.rs`, and passed down as typed fields. Don't re-resolve or re-parse it in transforms or handlers. - -## Transforms and the base config - -3. Every route defines a base config trait with `transform_request` + `transform_response` (+ `complete_url`, `supported_params`), living in `core/src//transformation.rs` (e.g. `AnthropicMessagesProviderConfig`, mirroring `OcrProviderConfig`). -4. Each provider implements that trait as a `const __CONFIG` in `core/src/providers///transformation.rs`, mirroring the Python provider tree. -5. Individual configs implement only the request/response transforms. Shared behavior (param filtering, defaults) stays as trait default methods so future providers inherit existing logic instead of reimplementing it. -6. Prefer composition: a provider that extends another reuses the base trait's defaults or wraps another config; don't copy transform bodies between providers. - -## Boundaries - -7. Layers never cross: `core` = the call itself (entrypoint, types, transforms, provider resolution, auth headers, provider HTTP, lifecycle hooks); `ai-gateway` = serving HTTP/WS (routing, extractors, auth of *our* callers, streaming to the client); `python-bridge` = thin PyO3 adapter. Hosts call the core entrypoint; they never build a provider request. -8. Generic/route files contain zero provider-specific branches. A provider is one module under `core/src/providers///`; a route is a module, never a new crate. -9. Route entry point stays thin: `core::::()` -> `prepare_*` -> handler (or `CallLifecycle::run_request`, which owns the pre_call -> during_call -> provider call -> success/failure order and phase timing). Axum handlers validate and delegate to a service that calls the entrypoint; no business logic in them. -10. Constants (URLs, env-var names, API versions, error messages) live in a crate `constants.rs`, never inline. Config-shaped env reads happen at the host/config layer with the `DEFAULT_*` fallback defined in `constants.rs`; the only env read in `core` is the credential fallback in a route's `prepare.rs`. - -## Types and errors - -11. Typed contracts only: no bare `serde_json::Value` / `String` / `Vec` as a transform input or output. Parse wire bytes into typed structs/enums at the host edge; a `type` discriminator is a typed field, not a raw string. -12. Model failures as values: return typed `CoreError`, don't panic. No `unwrap`/`expect`/`panic!` on user or provider input. -13. No mutation: build values in one shot (comprehensions/iterators, `collect`), prefer immutable bindings and owned typed structs over seeding-and-mutating. -14. Early returns over deep nesting; small focused files over god modules. -15. Preserve Python output shape intentionally. If a field is always serialized as `null` for parity, keep it and pin it with a test. - -## Safety and data minimization - -16. Never log request/response bodies, base64 payloads, document contents, or secrets. Truncate and bound any upstream body before it crosses a host boundary. -17. Treat empty/whitespace credentials, URLs, and config values as absent at the host resolution layer. -18. Network I/O sets connect + request timeouts (no unbounded waits), reuses a shared HTTP client, and prefers rustls TLS. - -## Tests and rollout - -19. Every provider transform ships tests for: supported-param filtering, request body shape, response normalization, missing/null fields, bad input, and `*_match_python` fixture parity. -20. Lifecycle/hook tests cover hook order, success + failure callback payloads, pre-call guardrail blocking before any provider I/O, during-call body mutation, and provider-error mapping. -21. When a route has a Python reference implementation, the Rust path stays off by default and behind Python parity tests (disabled / enabled-equals-Python / bridge-unavailable fallback) until parity is proven. A new provider/route may instead be implemented rust-only with no Python reference; then the Python interface is a thin dispatch to Rust with no fallback, and tests cover the rust-backed path plus the unavailable-bridge error. State the rust-only choice explicitly in the PR. - -## Python bridge (SDK side) - -22. A Python -> Rust bridge keeps the Python side minimal: the Python interface only marshals inputs and calls the Rust interface, with no transform, handler, or business logic. Aim for well under 100 lines of interface code per route; if the Python grows past that, the logic belongs in Rust. -23. Do not bloat `litellm/main.py`. A route's provider dispatch lives in a thin dispatch class under `litellm/llms///` that calls the Rust bridge; `main.py` only instantiates it and calls its sync/async method. -24. Do not add new feature flags unless explicitly requested. Reuse the existing LiteLLM Rust rollout mechanism (`litellm.rust`); never introduce a per-route env flag such as `LITELLM_USE_RUST_`. - -## Checks before push - -25. Run, and keep green, the commands under "Checks" in `litellm-rust/CLAUDE.md`. - That list is the single source of truth and matches what GitHub Actions runs. diff --git a/litellm-rust/crates/ai-gateway/AGENTS.md b/litellm-rust/crates/ai-gateway/AGENTS.md deleted file mode 100644 index b2fd583316b..00000000000 --- a/litellm-rust/crates/ai-gateway/AGENTS.md +++ /dev/null @@ -1,54 +0,0 @@ -# ai-gateway — folder architecture - -The Axum server that fronts the Rust gateway. It owns transport + config + auth -only; deployment selection lives in `core::router`, and the LLM call itself -(transforms, auth headers, provider HTTP) lives behind a `core` route entrypoint -such as `litellm_core::messages::messages`. No provider handler lives here. - -``` -src/ - main.rs # entrypoint: build AppState (router + master key), bind, serve - state.rs # AppState — shared Arc + master_key - auth/ # authentication as an axum extractor — added to handler args - mod.rs # RequireMasterKey: FromRequestParts, single master key (LITELLM_MASTER_KEY) - routes/ # one module per route, all matching the same template - AGENTS.md # ← the route template (read this before adding a route) - mod.rs # app(): merges every module's router() - health.rs # simple route (one file): router() + liveness/readiness - realtime/ # route with logic → axum surface + a no-axum service: - mod.rs # router() + handler + WS<->events adapter (the axum surface) - service.rs # business logic (select deployment, call provider) — no axum, testable -``` - -## Rules - -- **Routes follow one template.** Each route module exposes - `pub fn router() -> Router`; `routes/mod.rs` only merges them. Simple - routes are one file; non-trivial routes are a folder (`handler`/`service`/ - `transport`). See `routes/AGENTS.md`. -- **Auth is an extractor.** Add `crate::auth::RequireMasterKey` to a handler's - args; it runs during extraction. Never re-implement the check per route. -- **Handlers are thin.** A handler validates and delegates to its `service`. No - business logic, no provider calls, no transforms in handlers. -- **Services call `core`, they don't reimplement it.** A `service` picks the - deployment and calls the `core` route entrypoint. Provider resolution, auth - headers, URL building, and the HTTP call are `core`'s job; a service that - builds a provider request itself is a bug (`routes/messages/service.rs` is - the reference). -- **State is shared and cheap to clone.** Long-lived handles live behind `Arc` in - `state.rs`; read env/config only in `main.rs` when building state. - -## Auth (interim) - -A single **master key** (`LITELLM_MASTER_KEY`), enforced by the -`auth::RequireMasterKey` extractor: any caller presenting it as -`Authorization: Bearer ` may invoke the gateway. Fails closed (500) when -unset; constant-time compare. The server binds `127.0.0.1` by default (`HOST` to -override). Full per-key auth + budgets/rate-limits are delegated to the Python -proxy in a later phase. Health routes don't add the extractor (unauthenticated). - -## Python interop - -Python-backed loading lives in `litellm-config` and is **load-time only**. The -gateway's `python-config` feature forwards to that crate. The realtime data path -never takes the GIL. diff --git a/litellm-rust/crates/ai-gateway/ARCHITECTURE.md b/litellm-rust/crates/ai-gateway/ARCHITECTURE.md deleted file mode 100644 index 6d090cf4c8e..00000000000 --- a/litellm-rust/crates/ai-gateway/ARCHITECTURE.md +++ /dev/null @@ -1,14 +0,0 @@ -# ai-gateway architecture - -The Rust ai-gateway does LLM inference (realtime WebSocket). Spend tracking is an -API callback: it POSTs each finished session to the LiteLLM proxy, which records -spend and runs the usual callbacks. - -```mermaid -flowchart LR - C[client] <--> G[Rust ai-gateway
LLM inference] - G <--> O[OpenAI realtime] - G -. spend tracking callback .-> P[litellm proxy] - F[litellm-config
load-time only] --> G - F -. Python backend .-> P -``` diff --git a/litellm-rust/crates/ai-gateway/Cargo.toml b/litellm-rust/crates/ai-gateway/Cargo.toml index 74cf66e88a2..dfa61226d4e 100644 --- a/litellm-rust/crates/ai-gateway/Cargo.toml +++ b/litellm-rust/crates/ai-gateway/Cargo.toml @@ -13,6 +13,11 @@ name = "litellm-ai-gateway" path = "src/main.rs" required-features = ["server"] +[[bin]] +name = "trace-parity-gateway" +path = "src/bin/trace_parity_gateway.rs" +required-features = ["trace-parity"] + [dependencies] tracing.workspace = true litellm-core = { workspace = true, features = ["bedrock-auth"] } diff --git a/litellm-rust/crates/ai-gateway/benchmarks/realtime/README.md b/litellm-rust/crates/ai-gateway/benchmarks/realtime/README.md deleted file mode 100644 index 84e926af243..00000000000 --- a/litellm-rust/crates/ai-gateway/benchmarks/realtime/README.md +++ /dev/null @@ -1,55 +0,0 @@ -# Realtime gateway benchmark — pool on/off - -Measures what the gateway adds over talking to OpenAI's realtime WebSocket -directly, and what the pre-warmed connection pool removes. See -`../../src/routes/realtime/README.md` for how the pool works. - -## Results - -5000 calls / 500 concurrency, gateway at 10 instances, pool ON -(`REALTIME_POOL_SIZE=64`), upstream OpenAI `gpt-realtime`. Each leg run twice. -Times in **ms**. Phases per connection: **dial** = TCP+TLS+WS upgrade, -**session** = upgrade → `session.created` (the phase the pool removes), -**1st-audio** = `response.create` → first audio delta (OpenAI inference), -**total** = full wall-clock. - -| metric | Direct OpenAI | Gateway (pool ON) | Overhead (ms) | vs OpenAI | -| ------------------ | ------------- | ----------------- | ------------- | ---------- | -| success rate (%) | 99.8 | 99.8 | — | — | -| dial p50 (ms) | 276 | 158 | −118 | **faster** | -| session p50 (ms) | 7 | 0 | −7 | **faster** | -| 1st-audio p50 (ms) | 440 | 664 | +224 | slower¹ | -| total p50 (ms) | 816 | 1010 | +194 | slower¹ | -| total p95 (ms) | 2152 | 1970 | −182 | **faster** | -| total p99 (ms) | 2692 | 2610 | −82 | **faster** | - -The gateway is **faster than direct on 4 of 6 metrics**. The warm pool makes the -**session phase sub-millisecond** at the median — ~76% of connects hit the pool, -~70% had session < 1 ms. ¹ The two "slower" rows are not gateway overhead: -`1st-audio` is OpenAI's own inference time (the gateway only relays it), which ran -slower during the gateway legs and drags `total p50` with it. - -**Pool OFF** (control, `REALTIME_POOL_SIZE=0`): session p50 was **367 ms** — the -fresh-dial overhead the pool removes. - -## Reproduce - -The load generator lives in a separate repo: -**https://github.com/ishaan-berri/litellm-realtime-bench** - -```bash -git clone https://github.com/ishaan-berri/litellm-realtime-bench -cd litellm-realtime-bench && go build -o wsbench . - -# Direct to OpenAI (baseline) -./wsbench -host api.openai.com -key "$OPENAI_API_KEY" -m gpt-realtime -n 5000 -c 500 -t 60 - -# Through the gateway — run once with pool ON, once with REALTIME_POOL_SIZE=0 -./wsbench -host -key "$LITELLM_MASTER_KEY" -m gpt-realtime -n 5000 -c 500 -t 60 -``` - -Run the gateway with the env stand-in (`OPENAI_REALTIME_MODEL=gpt-realtime`, -`OPENAI_API_KEY`, `LITELLM_MASTER_KEY`, `REALTIME_POOL_SIZE`, `HOST=0.0.0.0`). At -500 concurrency over N instances, size the pool to `≈ 500 / N` per instance (64 was -used here for 10 instances). The bench repo's README covers running 500-concurrency -legs from a hosted multi-vCPU runner. **Never commit keys — pass them via `-key`.** diff --git a/litellm-rust/crates/ai-gateway/src/audio_transcription/hooks.rs b/litellm-rust/crates/ai-gateway/src/audio_transcription/hooks.rs index 6f48f38c9f6..b17f17de11f 100644 --- a/litellm-rust/crates/ai-gateway/src/audio_transcription/hooks.rs +++ b/litellm-rust/crates/ai-gateway/src/audio_transcription/hooks.rs @@ -277,7 +277,7 @@ fn core_error_kind(error: &Error) -> &'static str { Error::InvalidProvider(_) => "InvalidProvider", Error::InvalidRequest(_) => "InvalidRequest", Error::InvalidType { .. } => "InvalidType", - Error::MissingField(_) => "MissingField", + Error::MissingField(_) | Error::MissingDocumentUrl => "MissingField", Error::Http { .. } => "HttpError", Error::InvalidResponse(_) => "InvalidResponse", Error::Network(_) => "NetworkError", diff --git a/litellm-rust/crates/ai-gateway/src/bin/trace_parity_gateway.rs b/litellm-rust/crates/ai-gateway/src/bin/trace_parity_gateway.rs new file mode 100644 index 00000000000..9036deb9871 --- /dev/null +++ b/litellm-rust/crates/ai-gateway/src/bin/trace_parity_gateway.rs @@ -0,0 +1,40 @@ +use std::io::Read; + +use serde::Deserialize; +use serde_json::Value; + +#[derive(Deserialize)] +struct Input { + model_alias: String, + provider_model: String, + api_base: String, + body: Value, +} + +#[tokio::main] +async fn main() { + let mut input = String::new(); + if let Err(error) = std::io::stdin().read_to_string(&mut input) { + fail(error); + } + let input: Input = match serde_json::from_str(&input) { + Ok(input) => input, + Err(error) => fail(error), + }; + let result = litellm_ai_gateway::trace_parity::traced_messages_request( + input.model_alias, + input.provider_model, + input.api_base, + input.body, + ) + .await; + match serde_json::to_string(&result) { + Ok(result) => println!("{result}"), + Err(error) => fail(error), + } +} + +fn fail(error: impl std::fmt::Display) -> ! { + eprintln!("{error}"); + std::process::exit(1) +} diff --git a/litellm-rust/crates/ai-gateway/src/io/responses_ws.rs b/litellm-rust/crates/ai-gateway/src/io/responses_ws.rs index 7f3b6b0650f..f86dd778424 100644 --- a/litellm-rust/crates/ai-gateway/src/io/responses_ws.rs +++ b/litellm-rust/crates/ai-gateway/src/io/responses_ws.rs @@ -1,5 +1,3 @@ -use std::collections::HashMap; -use std::sync::Arc; use std::time::Duration; use futures_util::stream::{SplitSink, SplitStream}; @@ -10,106 +8,21 @@ use litellm_core::auth::error::MissingCredential; use litellm_core::providers::openai::responses::transformation::OPENAI_RESPONSES_WS_CONFIG; use litellm_core::responses::types::ResponsesWsEvent; use litellm_core::responses::websocket::ResponsesWebSocketProviderConfig; -use tokio::net::TcpStream; -use tokio::sync::Mutex; use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::tungstenite::client::IntoClientRequest; use tokio_tungstenite::tungstenite::http::HeaderValue; -use tokio_tungstenite::tungstenite::http::header::{AUTHORIZATION, HeaderName}; -use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; +use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION; -use crate::io::tls::connect_upstream; +use litellm_core::responses::websocket::{ResponsesUpstreamWs, connect_upstream}; use crate::constants::{ DEFAULT_RESPONSES_WS_CONNECT_TIMEOUT_SECS, DEFAULT_RESPONSES_WS_IDLE_TIMEOUT_SECS, }; const OPENAI_API_KEY_ENV: &str = "OPENAI_API_KEY"; -pub type ResponsesUpstreamWs = WebSocketStream>; type UpstreamTx = SplitSink; type UpstreamRx = SplitStream; -#[derive(Clone)] -pub struct ResponsesWebSocketConnection { - socket: Arc>>, -} - -impl ResponsesWebSocketConnection { - pub async fn connect_url( - url: &str, - headers: &HashMap, - timeout: Option, - ) -> Result { - let mut request = url - .into_client_request() - .map_err(|error| Error::Network(error.to_string()))?; - for (name, value) in headers { - let header_name = name - .parse::() - .map_err(|error| Error::InvalidRequest(error.to_string()))?; - let header_value = HeaderValue::from_str(value) - .map_err(|error| Error::InvalidRequest(error.to_string()))?; - request.headers_mut().insert(header_name, header_value); - } - let connect = connect_upstream(request); - let result = match timeout { - Some(timeout) => tokio::time::timeout(timeout, connect).await.map_err(|_| { - Error::Network("Responses WebSocket connection timed out".to_string()) - })?, - None => connect.await, - }; - let (socket, _) = result.map_err(|error| match *error { - tokio_tungstenite::tungstenite::Error::Http(response) => Error::Http { - status: response.status().as_u16(), - body: String::new(), - }, - other => Error::Network(other.to_string()), - })?; - Ok(Self { - socket: Arc::new(Mutex::new(Some(socket))), - }) - } - - pub async fn send_text(&self, text: String) -> Result<(), Error> { - let mut socket = self.socket.lock().await; - let Some(socket) = socket.as_mut() else { - return Err(Error::Network("Responses WebSocket is closed".to_string())); - }; - socket - .send(Message::Text(text)) - .await - .map_err(|error| Error::Network(error.to_string())) - } - - pub async fn recv_text(&self) -> Result, Error> { - let mut socket_guard = self.socket.lock().await; - let Some(socket) = socket_guard.as_mut() else { - return Ok(None); - }; - match socket.next().await { - Some(Ok(Message::Text(text))) => Ok(Some(text)), - Some(Ok(Message::Binary(bytes))) => String::from_utf8(bytes.to_vec()) - .map(Some) - .map_err(|error| Error::InvalidResponse(error.to_string())), - Some(Ok(Message::Close(_))) | None => Ok(None), - Some(Ok(_)) => Ok(None), - Some(Err(error)) => Err(Error::Network(error.to_string())), - } - } - - pub async fn close(&self) -> Result<(), Error> { - let mut socket = self.socket.lock().await; - if let Some(socket) = socket.as_mut() { - socket - .close(None) - .await - .map_err(|error| Error::Network(error.to_string()))?; - } - *socket = None; - Ok(()) - } -} - pub(crate) fn resolve_api_key(api_key: Option<&str>) -> Result { api_key .map(str::trim) diff --git a/litellm-rust/crates/ai-gateway/src/routes/messages/mod.rs b/litellm-rust/crates/ai-gateway/src/routes/messages/mod.rs index 39465e28e84..3334053a0a4 100644 --- a/litellm-rust/crates/ai-gateway/src/routes/messages/mod.rs +++ b/litellm-rust/crates/ai-gateway/src/routes/messages/mod.rs @@ -118,7 +118,8 @@ impl IntoResponse for MessagesRouteError { | Error::Connect(_) | Error::InvalidResponse(_) | Error::InvalidType { .. } - | Error::MissingField(_) => ( + | Error::MissingField(_) + | Error::MissingDocumentUrl => ( StatusCode::BAD_GATEWAY, "messages provider request failed".to_string(), ), diff --git a/litellm-rust/crates/ai-gateway/src/trace_parity.rs b/litellm-rust/crates/ai-gateway/src/trace_parity.rs index 21123df3f1c..00c9b53e691 100644 --- a/litellm-rust/crates/ai-gateway/src/trace_parity.rs +++ b/litellm-rust/crates/ai-gateway/src/trace_parity.rs @@ -10,6 +10,7 @@ use litellm_core::router::{Deployment, LiteLLMParams, Router as ModelRouter}; use serde::Serialize; use serde_json::Value; use tower::ServiceExt; +use tracing::instrument::WithSubscriber; use crate::io::realtime_pool::RealtimePool; use crate::routes; @@ -21,6 +22,38 @@ pub struct GatewayResponse { pub body: Value, } +#[derive(Debug, Serialize)] +pub struct TracedGatewayResponse { + pub response: Option, + pub error: Option, + pub trace: Vec, +} + +pub async fn traced_messages_request( + model_alias: String, + provider_model: String, + api_base: String, + body: Value, +) -> TracedGatewayResponse { + let trace = litellm_core::observability::FunctionTrace::default(); + let result = messages_request(model_alias, provider_model, api_base, body) + .with_subscriber(trace.dispatcher()) + .await; + let events = trace.events(); + match result { + Ok(response) => TracedGatewayResponse { + response: Some(response), + error: None, + trace: events, + }, + Err(error) => TracedGatewayResponse { + response: None, + error: Some(error.to_string()), + trace: events, + }, + } +} + pub async fn messages_request( model_alias: String, provider_model: String, diff --git a/litellm-rust/crates/ai-gateway/tests/crypto_provider_wiring.rs b/litellm-rust/crates/ai-gateway/tests/crypto_provider_wiring.rs index 05f7d9610d5..ac37440d682 100644 --- a/litellm-rust/crates/ai-gateway/tests/crypto_provider_wiring.rs +++ b/litellm-rust/crates/ai-gateway/tests/crypto_provider_wiring.rs @@ -2,10 +2,10 @@ //! API has to resolve its own crypto provider, in a test binary where nothing //! has installed a process-wide one, and has to leave it uninstalled. -use std::collections::HashMap; use std::time::Duration; -use litellm_ai_gateway::io::responses_ws::ResponsesWebSocketConnection; +use futures_util::{sink, stream}; +use litellm_ai_gateway::io::responses_ws::async_responses_websocket; use tokio::net::TcpListener; async fn dead_tls_server() -> u16 { @@ -30,10 +30,15 @@ async fn dead_tls_server() -> u16 { async fn dialing_wss_returns_an_error_instead_of_panicking() { let port = dead_tls_server().await; - let result = ResponsesWebSocketConnection::connect_url( - &format!("wss://127.0.0.1:{port}/"), - &HashMap::new(), + let result = async_responses_websocket( + "gpt-5", + Some("test-key"), + Some(&format!("wss://127.0.0.1:{port}/")), + None, Some(Duration::from_secs(10)), + |_| {}, + stream::empty(), + sink::drain(), ) .await; diff --git a/litellm-rust/crates/core/AGENTS.md b/litellm-rust/crates/core/AGENTS.md index aee8b4937ef..9ba7bfb5323 100644 --- a/litellm-rust/crates/core/AGENTS.md +++ b/litellm-rust/crates/core/AGENTS.md @@ -2,6 +2,6 @@ litellm-core is the LiteLLM SDK in Rust — it makes the LLM call. Each top-leve A route module owns everything the call needs: types, the provider template trait, provider transforms (under `providers/`), provider/auth/URL resolution, and the handler that performs the HTTP call. Handlers belong here, never in a host crate. -Not here: serving HTTP (axum routes, extractors), config file reading, rollout state, databases, or callback dispatch. 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 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`. Routes (messages, ocr, realtime) and providers (anthropic, mistral, openai) are modules, not crates. diff --git a/litellm-rust/crates/core/CLAUDE.md b/litellm-rust/crates/core/CLAUDE.md deleted file mode 100644 index 5d36305ded5..00000000000 --- a/litellm-rust/crates/core/CLAUDE.md +++ /dev/null @@ -1,66 +0,0 @@ -# CLAUDE.md - -Rules for `litellm-rust/crates/core`. - -## Responsibility - -`core` is the LiteLLM SDK in Rust: it makes the LLM call. Every top-level -LiteLLM call has a public entrypoint here, named after the route -(`messages::messages()` is the Rust equivalent of `litellm.messages()`), and -calling it returns a typed non-streaming response. - -Allowed: -- The public entrypoint for a route, plus its `_stream` variant when the - route supports streaming. -- Provider resolution, auth header construction, URL building, and the provider - HTTP call (shared reused client, connect + request timeouts). -- Shared request/response structs. -- Typed errors with stable, non-sensitive messages. -- Deterministic validation helpers. -- Serialization helpers that intentionally mirror Python output shape. -- Route templates that match Python base config responsibilities, such as - `messages::transformation::AnthropicMessagesProviderConfig`. - -Not allowed: -- Serving HTTP: axum routers, extractors, and other transport concerns. -- Filesystem, database, or cache access. -- Config file reading or rollout state; the host resolves those and passes them - in. Env reads are limited to credential fallback in a route's `prepare.rs`. -- Logging callbacks, tracing spans, spend writes, or customer callbacks. -- Provider-specific branching that belongs in `providers`. -- Panics for user/provider-controlled input. - -## Typed Contracts (core rule) - -Trait and function boundaries MUST be strongly typed. No stringly-typed JSON -(`&str` / `String` / `Vec` / bare `serde_json::Value`) as a transform -input or output. Parse wire bytes into typed structs/enums at the host edge; -`core` and `providers` operate only on those types (e.g. `RealtimeEvent`, -`RealtimeTransformResult`, `OcrRequestData`). A `type`-style discriminator is a -typed field on a struct, not a raw string threaded through the API. - -## Structure - -Use route names directly under `src/`: `messages`, `ocr`, future -`chat_completions`, `embeddings`, and similar top-level LiteLLM calls. Do not -invent broad names like `engine` for route contracts. - -`src/messages` is the reference shape for a route module: - -``` -mod.rs pub async fn messages(..) (+ messages_stream) -types.rs request/response types -transformation.rs the provider template trait -prepare.rs provider resolution, auth headers, URL -handler.rs the provider call -client.rs the shared reqwest client -``` - -## Parity Rules - -- Every shared type used by a provider transform needs unit tests for - serialization shape. -- If Python parity requires always emitting a `null` field instead of omitting - it, document that in code and pin it with a test. -- Error enums should preserve enough detail for Python/HTTP hosts to map errors - consistently without exposing document contents or upstream bodies. diff --git a/litellm-rust/crates/core/Cargo.toml b/litellm-rust/crates/core/Cargo.toml index a2433435e34..09c526f73cf 100644 --- a/litellm-rust/crates/core/Cargo.toml +++ b/litellm-rust/crates/core/Cargo.toml @@ -6,25 +6,27 @@ license.workspace = true repository.workspace = true autotests = false -[[test]] -name = "workspace_crate_allowlist" -path = "tests/workspace_crate_allowlist.rs" - [dependencies] +bytes.workspace = true +futures-util.workspace = true base64.workspace = true azure_core.workspace = true azure_identity.workspace = true data-url = "0.3.2" gcp_auth.workspace = true moka.workspace = true +mime_guess = "2.0.5" rand.workspace = true reqwest.workspace = true +rustls.workspace = true +rustls-native-certs.workspace = true serde.workspace = true serde_json.workspace = true serde_path_to_error = "0.1" strum.workspace = true subtle.workspace = true -tokio.workspace = true +tokio = { workspace = true, features = ["sync"] } +tokio-tungstenite.workspace = true thiserror.workspace = true tracing.workspace = true tracing-subscriber = { workspace = true, optional = true } diff --git a/litellm-rust/crates/core/src/auth/credential.rs b/litellm-rust/crates/core/src/auth/credential.rs index b5235b6780c..c64d331b877 100644 --- a/litellm-rust/crates/core/src/auth/credential.rs +++ b/litellm-rust/crates/core/src/auth/credential.rs @@ -9,6 +9,21 @@ use crate::AuthError; 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/core/src/auth/mod.rs b/litellm-rust/crates/core/src/auth/mod.rs index 35d9c676f65..2940a983fb9 100644 --- a/litellm-rust/crates/core/src/auth/mod.rs +++ b/litellm-rust/crates/core/src/auth/mod.rs @@ -49,6 +49,7 @@ impl Sourced { pub use credential::{ CredentialFileRef, CredentialLookup, CredentialLookupFuture, CredentialPlan, CredentialPlanResolution, CredentialRef, CredentialResolver, CredentialResolverHandle, + credential_default_fields, credential_index, }; pub use http::{CredentialPlacement, RequestAuth}; pub use policy::{CredentialPlanKind, CredentialRule, ExistingHeaderBehavior, ProviderAuthPolicy}; diff --git a/litellm-rust/crates/core/src/call_lifecycle/README.md b/litellm-rust/crates/core/src/call_lifecycle/README.md deleted file mode 100644 index 692e249ef27..00000000000 --- a/litellm-rust/crates/core/src/call_lifecycle/README.md +++ /dev/null @@ -1,167 +0,0 @@ -# Call lifecycle - -`litellm_core::call_lifecycle` is the shared execution wrapper for LiteLLM call -types migrated to Rust. It owns lifecycle ordering, phase timing, and trace -observer calls. It must not know about OCR, chat, messages, responses, -completions, provider auth, request transforms, or response normalization. - -Call-type modules own their domain behavior. For example, OCR owns document -payloads, OCR provider transforms, safe document fetch, guardrail payload shape, -callback payload shape, and provider HTTP execution. - -## Runtime order - -Every wrapped call runs in this order: - -1. `async_pre_call_hook` -2. `async_during_call_hook` -3. provider call -4. `async_log_success_event` or `async_log_failure_event` - -`async_pre_call_hook` receives the initial LiteLLM request shape. It is where -pre-call custom guardrails run. - -`async_during_call_hook` converts the initial request into the provider-ready -request. It is where provider config selection, parameter mapping, auth/header -resolution, request transforms, and during-call guardrails belong. - -The provider call receives only the provider-ready request. It should execute -I/O and call the provider response transform. - -Success and failure callbacks receive `CallLifecycleTiming`. Callback failures -must not replace the original provider or guardrail result. - -## Trace contract - -The lifecycle runner records: - -- full call start and end time -- `pre_call` phase timing -- `during_call` phase timing -- `provider_call` phase timing -- `success_callback` phase timing -- `failure_callback` phase timing - -`CallLifecycleObserver` receives phase start and end events. The default -observer is a no-op. Future OTEL support should implement this observer instead -of editing OCR, chat, messages, responses, completions, or provider modules. - -## Required shape - -Each migrated call type should use this folder shape: - -```text -litellm-rust/crates/ai-gateway/src// - mod.rs # thin public entrypoint - types.rs # public request, prepared request, provider request, response types - prepare.rs # model/provider/callback/guardrail setup - hooks.rs # CallLifecycleHooks implementation - handler.rs # provider I/O and response normalization - tests.rs # call-type lifecycle and handler tests -``` - -Provider transforms can live in `litellm-rust/crates/core/src/providers/...`. -Shared call-type helpers can live beside the call type, but generic lifecycle -code stays in this folder. - -## Core API - -The prepared request implements `CallLifecycleRequest`: - -```rust -impl CallLifecycleRequest for PreparedMessagesRequest { - fn lifecycle_context(&self) -> CallLifecycleContext { - CallLifecycleContext::new( - "messages", - self.model.clone(), - self.custom_llm_provider.clone(), - self.litellm_call_id.clone(), - ) - } -} -``` - -The call-type hooks implement `CallLifecycleHooks`: - -```rust -impl CallLifecycleHooks< - PreparedMessagesRequest, - ProviderMessagesRequest, - MessagesResponse, -> for MessagesLifecycleHooks { - fn async_pre_call_hook(...) { - // run pre-call custom guardrails against the LiteLLM request shape - } - - fn async_during_call_hook(...) { - // map params, validate env, transform request, run during-call guardrails - } - - fn async_log_success_event(...) { - // call async_log_success_event on configured custom loggers - } - - fn async_log_failure_event(...) { - // call async_log_failure_event without swallowing the original error - } -} -``` - -The public entrypoint stays thin: - -```rust -pub async fn messages(request: MessagesRequest<'_>) -> CoreResult { - let PreparedMessagesCall { request, hooks } = prepare_messages_call(request)?; - - CallLifecycle::default() - .run_request(request, &hooks, execute_messages_provider_call) - .await -} -``` - -Use `run_request` for new call types. Keep `run` available only for specialized -tests or existing code that already has a `CallLifecycleContext`. - -## Adding a new call type - -1. Add `/types.rs` - -Define the public request accepted by the bridge, the prepared request used by -the lifecycle runner, and the provider request consumed by the handler. - -2. Implement `CallLifecycleRequest` - -Return `call_type`, `model`, `custom_llm_provider`, and `litellm_call_id`. -Do not put provider-specific logic here. - -3. Add `/prepare.rs` - -Resolve model/provider once, generate or preserve `litellm_call_id`, construct -callback and guardrail runners, and return `PreparedCall`. - -4. Add `/hooks.rs` - -Implement `CallLifecycleHooks`. Put pre-call guardrail payload construction, -provider config selection, param mapping, request transform, during-call -guardrail payload construction, and callback payload construction here. - -5. Add `/handler.rs` - -Execute the provider request and normalize the provider response. Do not repeat -provider-specific transforms here; call the provider config. - -6. Add tests - -Cover hook order, success callback payload, failure callback payload, pre-call -guardrail blocking before provider I/O, during-call body mutation, and provider -error mapping. - -## Review checklist - -- Core lifecycle has no call-type or provider-specific branches -- Public call-type entrypoint only prepares and calls `run_request` -- Provider behavior lives behind provider config/transformation code -- Hook method names map to the Python custom logger and guardrail concepts -- Phase timing is recorded once in lifecycle, not separately per call type -- Callback failures never hide the original provider or guardrail error -- Tests prove the provider socket is not touched when pre-call guardrails block diff --git a/litellm-rust/crates/core/src/call_lifecycle/host.rs b/litellm-rust/crates/core/src/call_lifecycle/host.rs new file mode 100644 index 00000000000..ac6ddf99b9e --- /dev/null +++ b/litellm-rust/crates/core/src/call_lifecycle/host.rs @@ -0,0 +1,121 @@ +use std::future::Future; +use std::pin::Pin; + +pub enum HostCallStep { + Host(O), + Complete(C), +} + +pub type HostCallFuture<'a, O, C> = + Pin, crate::Error>> + Send + 'a>>; + +pub trait HostCall: Send + Sync { + type Operation: Send + 'static; + type Result: Send + 'static; + type Complete: Send + 'static; + + fn resume( + &mut self, + result: Option, + ) -> HostCallFuture<'_, Self::Operation, Self::Complete>; + + fn interrupt( + &mut self, + failure: HostFailure, + ) -> HostCallFuture<'_, Self::Operation, Self::Complete>; +} + +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(crate::Error), + Cancelled(crate::Error), +} + +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 index 637c156e192..5c752a73899 100644 --- a/litellm-rust/crates/core/src/call_lifecycle/mod.rs +++ b/litellm-rust/crates/core/src/call_lifecycle/mod.rs @@ -3,6 +3,10 @@ use std::time::{Instant, SystemTime, UNIX_EPOCH}; use crate::Error; +pub mod host; +#[cfg(test)] +#[path = "../../tests/host_lifecycle.rs"] +mod host_tests; pub mod types; pub use types::{ diff --git a/litellm-rust/crates/core/src/constants.rs b/litellm-rust/crates/core/src/constants.rs index 9469d379462..1babb0078b8 100644 --- a/litellm-rust/crates/core/src/constants.rs +++ b/litellm-rust/crates/core/src/constants.rs @@ -46,9 +46,10 @@ pub const FUNCTION_TRACE_TARGET: &str = "litellm::function_trace"; pub(crate) const MEDIA_CONNECT_TIMEOUT_SECS: u64 = 10; +pub(crate) const OCR_RESPONSE_MAX_BYTES: usize = 64 * 1024 * 1024; pub(crate) const OCR_HTTP_TIMEOUT_SECS: u64 = 600; pub(crate) const OCR_CONNECT_TIMEOUT_SECS: u64 = 10; -pub(crate) const OCR_INLINE_MAX_BYTES: usize = 50 * 1024 * 1024; +pub const OCR_INLINE_MAX_BYTES: usize = 50 * 1024 * 1024; pub(crate) const OCR_DOWNLOAD_MAX_BYTES: u64 = 50 * 1024 * 1024; pub(crate) const OCR_MAX_FETCH_REDIRECTS: usize = 10; pub(crate) const OCR_POLL_TIMEOUT_SECS: u64 = 120; @@ -63,3 +64,6 @@ pub(crate) const REDUCTO_API_KEY_ENV: &str = "REDUCTO_API_KEY"; pub(crate) const REDUCTO_ID_PREFIX: &str = "reducto://"; pub(crate) const AZURE_AI_OCR_PATH: &str = "/providers/mistral/azure/ocr"; pub(crate) const MISTRAL_OCR_API_BASE: &str = "https://api.mistral.ai/v1"; + +pub(crate) const COHERE_PARSE_API_BASE: &str = "https://api.cohere.com"; +pub(crate) const COHERE_API_KEY_ENV: &str = "COHERE_API_KEY"; diff --git a/litellm-rust/crates/core/src/error.rs b/litellm-rust/crates/core/src/error.rs index fa4a9d36e03..359ad56c336 100644 --- a/litellm-rust/crates/core/src/error.rs +++ b/litellm-rust/crates/core/src/error.rs @@ -1,6 +1,6 @@ use thiserror::Error as ThisError; -#[derive(Debug, ThisError, PartialEq, Eq)] +#[derive(Clone, Debug, ThisError, PartialEq, Eq)] pub enum Error { #[error("expected {expected}, got {actual}")] InvalidType { @@ -9,6 +9,8 @@ pub enum Error { }, #[error("missing required field: {0}")] MissingField(&'static str), + #[error("Document URL is required")] + MissingDocumentUrl, #[error("invalid response: {0}")] InvalidResponse(String), #[error("invalid provider: {0}")] @@ -52,6 +54,17 @@ pub enum Error { Unsupported(&'static str), } +impl Error { + pub const fn http_status_code(&self) -> Option { + match self { + Self::InvalidRequest(_) => Some(400), + Self::MissingDocumentUrl => Some(500), + Self::Http { status, .. } => Some(*status), + _ => None, + } + } +} + #[derive(Debug, ThisError)] pub(crate) enum MediaError { #[error("media URL rejected by network policy")] @@ -106,6 +119,7 @@ impl From for Error { fn from(error: crate::ocr::error::OcrRequestError) -> Self { match error { crate::ocr::error::OcrRequestError::MissingField(field) => Self::MissingField(field), + crate::ocr::error::OcrRequestError::MissingDocumentUrl => Self::MissingDocumentUrl, error => Self::InvalidRequest(error.to_string()), } } diff --git a/litellm-rust/crates/core/src/ocr/adapters/azure/cohere.rs b/litellm-rust/crates/core/src/ocr/adapters/azure/cohere.rs new file mode 100644 index 00000000000..4c8455a171c --- /dev/null +++ b/litellm-rust/crates/core/src/ocr/adapters/azure/cohere.rs @@ -0,0 +1,131 @@ +use super::super::OcrAdapter; +use crate::Error; +use crate::ocr::OcrClient; +use crate::ocr::codecs::cohere::{ + CohereParams, CohereResponse, transform_request, transform_response, validate_document, +}; +use crate::ocr::document::{inline_remote_document, validate_inline_document}; +use crate::ocr::error::{OcrError, OcrRequestError, OcrResponseError}; +use crate::ocr::prepare::{credential_env, transform_request_body}; +use crate::ocr::registry::OcrProvider; +use crate::ocr::types::{LiteLLMOcrRequest, LiteLLMOcrResponse}; +use crate::providers::azure_ai::auth::AzureAuthInputs; +use crate::url_utils::ApiUrl; + +const AZURE_AI_API_BASE_ENV: &str = "AZURE_AI_API_BASE"; + +pub(crate) struct AzureCohereAdapter; + +impl OcrAdapter for AzureCohereAdapter { + type ProviderResponse = CohereResponse; + const PROVIDER: OcrProvider = OcrProvider::AzureAi; + + async fn prepare_request( + &self, + request: &LiteLLMOcrRequest, + client: &OcrClient, + ) -> Result { + let params = super::super::super::wire::decode_request_value::( + serde_json::Value::Object(request.optional_params.clone()), + "optional_params", + )?; + let mut config = AzureAuthInputs::from_sourced_optional_params( + &request.optional_params, + &request.input_sources, + ) + .map_err(Error::from)?; + config.azure_ad_token_provider = request.azure_ad_token_provider.clone(); + let base = request + .connection + .api_base + .clone() + .or_else(|| credential_env(AZURE_AI_API_BASE_ENV)) + .filter(|base| !base.trim().is_empty()) + .ok_or_else(|| { + Error::Auth( + "Missing Azure AI API Base - Set AZURE_AI_API_BASE or pass api_base".into(), + ) + })?; + let headers = + super::validate_ai_environment(&request.connection, &config, &credential_env).await?; + validate_document(&request.document)?; + let remote = request.document.source().starts_with("http://") + || request.document.source().starts_with("https://"); + let document = inline_remote_document( + client.document_fetcher(), + request.document.clone(), + &request.connection, + ) + .await?; + let body = transform_request(&request.model, document, params)?; + transform_request_body( + client, + request, + &complete_url(&base)?, + &headers, + !remote, + body, + |body| { + validate_document(&body.document)?; + validate_inline_document(&body.document) + }, + ) + .await + } + + fn transform_ocr_response( + &self, + request: &LiteLLMOcrRequest, + response: Self::ProviderResponse, + ) -> Result { + transform_response(&request.model, response) + } +} + +fn complete_url(base: &str) -> Result { + let mut url = reqwest::Url::parse(base).map_err(|_| invalid_api_base())?; + if !matches!(url.scheme(), "http" | "https") { + return Err(invalid_api_base().into()); + } + let path = url.path().trim_end_matches('/').to_string(); + if path.ends_with("/v2/parse") { + url.set_path(&path); + return Ok(url.into()); + } + url.set_path(path.strip_suffix("/models").unwrap_or(&path)); + ApiUrl::parse(url.as_str()) + .and_then(|url| url.complete_path(&["providers", "cohere", "v2", "parse"])) + .map(|url| url.into_string()) + .map_err(|_| invalid_api_base().into()) +} + +fn invalid_api_base() -> OcrRequestError { + OcrRequestError::RequestField { + path: "api_base".into(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn completes_foundry_urls_without_duplicate_paths_and_preserves_queries() { + for suffix in [ + "", + "/models", + "/providers/cohere/v2", + "/providers/cohere/v2/parse", + ] { + assert_eq!( + complete_url(&format!("https://example.com{suffix}?tenant=a")).unwrap(), + "https://example.com/providers/cohere/v2/parse?tenant=a" + ); + } + assert_eq!( + complete_url("https://example.com/v2/parse?tenant=a").unwrap(), + "https://example.com/v2/parse?tenant=a" + ); + assert!(complete_url("relative/path").is_err()); + } +} diff --git a/litellm-rust/crates/core/src/ocr/adapters/azure/document_intelligence/mod.rs b/litellm-rust/crates/core/src/ocr/adapters/azure/document_intelligence/mod.rs index 71ca69ddc58..e90c27ba59d 100644 --- a/litellm-rust/crates/core/src/ocr/adapters/azure/document_intelligence/mod.rs +++ b/litellm-rust/crates/core/src/ocr/adapters/azure/document_intelligence/mod.rs @@ -10,7 +10,6 @@ use crate::ocr::error::{OcrError, OcrRequestError, OcrResponseError}; use crate::ocr::prepare::{credential_env, transform_request_body}; use crate::ocr::registry::OcrProvider; use crate::ocr::types::{LiteLLMOcrRequest, LiteLLMOcrResponse, OcrConnection, OcrResponseFormat}; -use crate::ocr::wire::DecodedOcrResponse; use crate::providers::azure_ai::auth::AzureAuthInputs; use crate::url_utils::ApiUrl; @@ -32,18 +31,19 @@ impl OcrAdapter for AzureDocumentIntelligenceAdapter { client: &OcrClient, ) -> Result { let params = map_ocr_params(request)?; - let config = AzureAuthInputs::from_sourced_optional_params( + let mut config = AzureAuthInputs::from_sourced_optional_params( &request.optional_params, &request.input_sources, ) .map_err(Error::from)?; + config.azure_ad_token_provider = request.azure_ad_token_provider.clone(); let headers = validate_environment(&request.connection, &config, &credential_env).await?; let endpoint = nonblank(request.connection.api_base.clone()) .or_else(|| nonblank(credential_env(AZURE_DI_ENDPOINT_ENV))) .ok_or_else(|| Error::Auth("Missing Azure Document Intelligence API Base - Set AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT or pass api_base".into()))?; let url = get_complete_url(&endpoint, &request.model, ¶ms)?; let body = document_intelligence::transform_ocr_request(request.document.clone())?; - transform_request_body(client, request, &url, &headers, body, |_| Ok(())).await + transform_request_body(client, request, &url, &headers, false, body, |_| Ok(())).await } fn transform_ocr_response( @@ -61,7 +61,7 @@ impl OcrAdapter for AzureDocumentIntelligenceAdapter { url: &str, headers: &[(String, String)], request: &LiteLLMOcrRequest, - ) -> Result, OcrError> { + ) -> Result, OcrError> { polling::read_operation_response( client.polling_http(), response, @@ -69,6 +69,7 @@ impl OcrAdapter for AzureDocumentIntelligenceAdapter { headers, &request.connection, request.response_format()? == OcrResponseFormat::Native, + &request.hooks, ) .await } diff --git a/litellm-rust/crates/core/src/ocr/adapters/azure/document_intelligence/polling.rs b/litellm-rust/crates/core/src/ocr/adapters/azure/document_intelligence/polling.rs index 1bddea0da4f..6ed1e4441d4 100644 --- a/litellm-rust/crates/core/src/ocr/adapters/azure/document_intelligence/polling.rs +++ b/litellm-rust/crates/core/src/ocr/adapters/azure/document_intelligence/polling.rs @@ -1,3 +1,4 @@ +use std::sync::Arc; use std::time::Duration; use reqwest::Url; @@ -9,6 +10,7 @@ use crate::ocr::codecs::document_intelligence::{ AzureDocumentIntelligenceOperation, OperationStatus, }; use crate::ocr::error::{OcrError, OcrPollingError, OcrResponseError}; +use crate::ocr::hooks::OcrHooks; use crate::ocr::types::OcrConnection; use crate::ocr::wire::DecodedOcrResponse; @@ -19,24 +21,33 @@ pub(super) async fn read_operation_response( headers: &[(String, String)], connection: &OcrConnection, native: bool, + hooks: &Arc, ) -> Result, OcrError> { if response.status() != reqwest::StatusCode::ACCEPTED { - return read_json_response(response, native).await; + let bytes = + crate::ocr::client::read_response_bytes(response, connection.max_response_bytes) + .await?; + crate::ocr::handler::post_call(hooks, &bytes).await?; + return Ok(crate::ocr::wire::decode_response(&bytes, native)?); } let location = response .headers() .get("operation-location") .and_then(|value| value.to_str().ok()) - .ok_or(OcrPollingError::PollLocation)?; + .ok_or(OcrPollingError::PollLocation)? + .to_string(); let original = Url::parse(original_url).map_err(|_| OcrPollingError::PollOrigin)?; - let operation = Url::parse(location).map_err(|_| OcrPollingError::PollOrigin)?; + let operation = Url::parse(&location).map_err(|_| OcrPollingError::PollOrigin)?; if original.origin() != operation.origin() || !operation.username().is_empty() || operation.password().is_some() { return Err(OcrPollingError::PollOrigin.into()); } - poll_operation(http_client, operation, headers, connection, native).await + 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 } async fn poll_operation( @@ -45,6 +56,7 @@ async fn poll_operation( headers: &[(String, String)], connection: &OcrConnection, native: bool, + hooks: &Arc, ) -> Result, OcrError> { let deadline = Instant::now() .checked_add(connection.poll_timeout) @@ -75,12 +87,19 @@ async fn poll_operation( .max(1); let decoded = tokio::time::timeout_at( deadline, - read_json_response::(response, native), + read_json_response::( + response, + native, + connection.max_response_bytes, + ), ) .await .map_err(|_| OcrPollingError::PollTimeout)??; match &decoded.data.status { - Some(OperationStatus::Succeeded) => return Ok(decoded), + Some(OperationStatus::Succeeded) => { + crate::ocr::handler::post_call(hooks, decoded.text.as_bytes()).await?; + return Ok(decoded); + } Some(OperationStatus::Running | OperationStatus::NotStarted) => { tokio::time::timeout_at(deadline, tokio::time::sleep(Duration::from_secs(retry))) .await diff --git a/litellm-rust/crates/core/src/ocr/adapters/azure/mistral.rs b/litellm-rust/crates/core/src/ocr/adapters/azure/mistral.rs index 3107494d39e..8639590b05c 100644 --- a/litellm-rust/crates/core/src/ocr/adapters/azure/mistral.rs +++ b/litellm-rust/crates/core/src/ocr/adapters/azure/mistral.rs @@ -33,13 +33,16 @@ impl OcrAdapter for AzureMistralAdapter { known: params, extra_params: _extra_params, } = _prepare_ocr_request::(request)?; - let config = AzureAuthInputs::from_sourced_optional_params( + let mut config = AzureAuthInputs::from_sourced_optional_params( &request.optional_params, &request.input_sources, ) .map_err(Error::from)?; - let headers = validate_environment(&request.connection, &config, &credential_env).await?; + config.azure_ad_token_provider = request.azure_ad_token_provider.clone(); let url = get_complete_url(request.connection.api_base.as_deref(), &credential_env)?; + let headers = validate_environment(&request.connection, &config, &credential_env).await?; + let retains_document = !request.document.source().starts_with("http://") + && !request.document.source().starts_with("https://"); let document = inline_remote_document( client.document_fetcher(), request.document.clone(), @@ -47,9 +50,15 @@ impl OcrAdapter for AzureMistralAdapter { ) .await?; let body = mistral::transform_ocr_request(&request.model, document, ¶ms)?; - transform_request_body(client, request, &url, &headers, body, |body| { - validate_inline_document(&body.document) - }) + transform_request_body( + client, + request, + &url, + &headers, + retains_document, + body, + |body| validate_inline_document(&body.document), + ) .await } @@ -83,12 +92,15 @@ fn get_complete_url( }) } -async fn validate_environment( +pub(in crate::ocr::adapters) async fn validate_environment( connection: &OcrConnection, config: &AzureAuthInputs, env_lookup: &(dyn Fn(&str) -> Option + Sync), ) -> Result, OcrError> { if crate::http_utils::has_header(&connection.extra_headers, "authorization") { + if config.azure_ad_token_provider.is_some() { + super::resolve_entra(config, env_lookup).await?; + } super::validate_destination(connection, connection.extra_headers_source)?; return Ok(connection.extra_headers.clone()); } diff --git a/litellm-rust/crates/core/src/ocr/adapters/azure/mod.rs b/litellm-rust/crates/core/src/ocr/adapters/azure/mod.rs index 9c02a7471c9..3d30ae6d6bd 100644 --- a/litellm-rust/crates/core/src/ocr/adapters/azure/mod.rs +++ b/litellm-rust/crates/core/src/ocr/adapters/azure/mod.rs @@ -1,3 +1,4 @@ +mod cohere; mod document_intelligence; mod mistral; @@ -10,8 +11,10 @@ use crate::ocr::error::OcrError; use crate::ocr::types::OcrConnection; use crate::providers::azure_ai::auth::{AzureAuthInputs, AzureAuthService}; +pub(crate) use cohere::AzureCohereAdapter; pub(crate) use document_intelligence::AzureDocumentIntelligenceAdapter; pub(crate) use mistral::AzureMistralAdapter; +pub(super) use mistral::validate_environment as validate_ai_environment; async fn resolve_entra( config: &AzureAuthInputs, @@ -22,6 +25,10 @@ async fn resolve_entra( .get_or_init(AzureAuthService::default) .get_azure_ad_token(config, env_lookup) .await + .or_else(|error| match error { + crate::AuthError::EmptyAzureToken => Ok(None), + other => Err(other), + }) .map(|credential| { credential.map(|credential| { let source = credential.source(); diff --git a/litellm-rust/crates/core/src/ocr/adapters/cohere.rs b/litellm-rust/crates/core/src/ocr/adapters/cohere.rs new file mode 100644 index 00000000000..933ead7f7f7 --- /dev/null +++ b/litellm-rust/crates/core/src/ocr/adapters/cohere.rs @@ -0,0 +1,123 @@ +use super::OcrAdapter; +use crate::Error; +use crate::constants::{COHERE_API_KEY_ENV, COHERE_PARSE_API_BASE}; +use crate::ocr::OcrClient; +use crate::ocr::codecs::cohere::{ + CohereParams, CohereResponse, transform_request, transform_response, validate_document, +}; +use crate::ocr::error::{OcrError, OcrRequestError, OcrResponseError}; +use crate::ocr::prepare::{credential_env, transform_request_body}; +use crate::ocr::registry::OcrProvider; +use crate::ocr::types::{LiteLLMOcrRequest, LiteLLMOcrResponse, OcrConnection}; +use crate::url_utils::ApiUrl; + +pub(crate) struct CohereAdapter; + +impl OcrAdapter for CohereAdapter { + type ProviderResponse = CohereResponse; + const PROVIDER: OcrProvider = OcrProvider::Cohere; + + async fn prepare_request( + &self, + request: &LiteLLMOcrRequest, + client: &OcrClient, + ) -> Result { + let params = super::super::wire::decode_request_value::( + serde_json::Value::Object(request.optional_params.clone()), + "optional_params", + )?; + let headers = validate_environment(&request.connection, &credential_env)?; + let url = complete_url( + request + .connection + .api_base + .as_deref() + .unwrap_or(COHERE_PARSE_API_BASE), + )?; + let body = transform_request(&request.model, request.document.clone(), params)?; + transform_request_body(client, request, &url, &headers, true, body, |body| { + validate_document(&body.document) + }) + .await + } + + fn transform_ocr_response( + &self, + request: &LiteLLMOcrRequest, + response: Self::ProviderResponse, + ) -> Result { + transform_response(&request.model, response) + } +} + +fn complete_url(base: &str) -> Result { + let parsed = reqwest::Url::parse(base).map_err(|_| invalid_api_base())?; + if !matches!(parsed.scheme(), "http" | "https") { + return Err(invalid_api_base().into()); + } + ApiUrl::parse(base) + .and_then(|url| url.complete_path(&["v2", "parse"])) + .map(|url| url.into_string()) + .map_err(|_| invalid_api_base().into()) +} + +fn invalid_api_base() -> OcrRequestError { + OcrRequestError::RequestField { + path: "api_base".into(), + } +} + +fn validate_environment( + connection: &OcrConnection, + env_lookup: &(dyn Fn(&str) -> Option + Sync), +) -> Result, OcrError> { + if crate::http_utils::has_header(&connection.extra_headers, "authorization") { + return Ok(connection.extra_headers.clone()); + } + let key = connection + .api_key + .as_deref() + .map(str::trim) + .filter(|key| !key.is_empty()) + .map(str::to_string) + .or_else(|| env_lookup(COHERE_API_KEY_ENV).filter(|key| !key.trim().is_empty())) + .ok_or_else(|| { + Error::Auth("Missing COHERE_API_KEY - set it in the environment or pass api_key".into()) + })?; + Ok( + std::iter::once(("Authorization".into(), format!("Bearer {key}"))) + .chain(connection.extra_headers.clone()) + .collect(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn completes_provider_urls_without_duplicate_paths_and_preserves_queries() { + for suffix in ["", "/v2", "/v2/parse"] { + assert_eq!( + complete_url(&format!("https://example.com{suffix}?tenant=a")).unwrap(), + "https://example.com/v2/parse?tenant=a" + ); + } + } + + #[test] + fn rejects_invalid_urls_and_blank_keys() { + assert!(complete_url("relative/path").is_err()); + assert!(complete_url("ftp://example.com").is_err()); + assert!(matches!( + validate_environment( + &OcrConnection { + api_key: Some(" ".into()), + ..Default::default() + }, + &|_| None, + ), + Err(OcrError::Public(Error::Auth(_))) + )); + } +} diff --git a/litellm-rust/crates/core/src/ocr/adapters/mistral.rs b/litellm-rust/crates/core/src/ocr/adapters/mistral.rs index ea569ffb34f..cdbc2c3effc 100644 --- a/litellm-rust/crates/core/src/ocr/adapters/mistral.rs +++ b/litellm-rust/crates/core/src/ocr/adapters/mistral.rs @@ -33,7 +33,7 @@ impl OcrAdapter for MistralAdapter { let url = get_complete_url(request.connection.api_base.as_deref())?; let body = mistral::transform_ocr_request(&request.model, request.document.clone(), ¶ms)?; - transform_request_body(client, request, &url, &headers, body, |_| Ok(())).await + transform_request_body(client, request, &url, &headers, true, body, |_| Ok(())).await } fn transform_ocr_response( diff --git a/litellm-rust/crates/core/src/ocr/adapters/mod.rs b/litellm-rust/crates/core/src/ocr/adapters/mod.rs index 9171d11836c..d473fcad280 100644 --- a/litellm-rust/crates/core/src/ocr/adapters/mod.rs +++ b/litellm-rust/crates/core/src/ocr/adapters/mod.rs @@ -5,15 +5,16 @@ use serde::de::DeserializeOwned; use super::OcrClient; use super::error::{OcrError, OcrResponseError}; use super::registry::OcrProvider; -use super::types::{LiteLLMOcrRequest, LiteLLMOcrResponse, OcrResponseFormat}; -use super::wire::DecodedOcrResponse; +use super::types::{LiteLLMOcrRequest, LiteLLMOcrResponse}; mod azure; +mod cohere; mod mistral; mod reducto; mod vertex; -pub(crate) use azure::{AzureDocumentIntelligenceAdapter, AzureMistralAdapter}; +pub(crate) use azure::{AzureCohereAdapter, AzureDocumentIntelligenceAdapter, AzureMistralAdapter}; +pub(crate) use cohere::CohereAdapter; pub(crate) use mistral::MistralAdapter; pub(crate) use reducto::{ReductoLegacyAdapter, ReductoV3Adapter}; pub(crate) use vertex::{VertexDeepSeekAdapter, VertexMistralAdapter}; @@ -55,18 +56,27 @@ pub(crate) trait OcrAdapter: Send + Sync + Sized + 'static { _url: &str, _headers: &[(String, String)], request: &LiteLLMOcrRequest, - ) -> impl Future, OcrError>> + Send - { - let retain_native = request - .response_format() - .map(|format| format == OcrResponseFormat::Native); - async move { super::client::read_json_response(response, retain_native?).await } + ) -> impl Future< + Output = Result, OcrError>, + > + Send { + async move { + let bytes = + super::client::read_response_bytes(response, request.connection.max_response_bytes) + .await?; + super::handler::post_call(&request.hooks, &bytes).await?; + Ok(super::wire::decode_response( + &bytes, + request.response_format()? == super::types::OcrResponseFormat::Native, + )?) + } } } macro_rules! for_each_ocr_adapter { ($callback:ident) => { $callback! { + Cohere, $crate::ocr::adapters::CohereAdapter, $crate::ocr::adapters::CohereAdapter, Cohere; + AzureCohere, $crate::ocr::adapters::AzureCohereAdapter, $crate::ocr::adapters::AzureCohereAdapter, AzureAi; Mistral, $crate::ocr::adapters::MistralAdapter, $crate::ocr::adapters::MistralAdapter, Mistral; AzureMistral, $crate::ocr::adapters::AzureMistralAdapter, $crate::ocr::adapters::AzureMistralAdapter, AzureAi; AzureDocumentIntelligence, $crate::ocr::adapters::AzureDocumentIntelligenceAdapter, $crate::ocr::adapters::AzureDocumentIntelligenceAdapter, AzureAi; diff --git a/litellm-rust/crates/core/src/ocr/adapters/reducto/legacy.rs b/litellm-rust/crates/core/src/ocr/adapters/reducto/legacy.rs index 062a0071a34..8889bcd1b45 100644 --- a/litellm-rust/crates/core/src/ocr/adapters/reducto/legacy.rs +++ b/litellm-rust/crates/core/src/ocr/adapters/reducto/legacy.rs @@ -27,7 +27,7 @@ impl OcrAdapter for ReductoLegacyAdapter { } = _prepare_ocr_request::(request)?; let headers = super::validate_environment(&request.connection, &credential_env)?; let url = super::get_complete_url(request.connection.api_base.as_deref(), "parse")?; - let document = guardrail_document(request, &url).await?; + let (document, headers) = guardrail_document(request, &url, &headers).await?; let document = super::prepare_document(client, document, &request.connection, &headers).await?; let body = reducto::transform_legacy_ocr_request(&request.model, document, ¶ms)?; diff --git a/litellm-rust/crates/core/src/ocr/adapters/reducto/mod.rs b/litellm-rust/crates/core/src/ocr/adapters/reducto/mod.rs index 7621d0d326a..2dafe291674 100644 --- a/litellm-rust/crates/core/src/ocr/adapters/reducto/mod.rs +++ b/litellm-rust/crates/core/src/ocr/adapters/reducto/mod.rs @@ -93,7 +93,7 @@ pub(super) async fn prepare_document( .map_err(crate::error::TransportError::from)?; let uploaded = crate::ocr::client::read_json_response::< crate::ocr::codecs::reducto::ReductoUploadResponse, - >(response, false) + >(response, false, connection.max_response_bytes) .await? .data; let file_id = uploaded diff --git a/litellm-rust/crates/core/src/ocr/adapters/reducto/v3.rs b/litellm-rust/crates/core/src/ocr/adapters/reducto/v3.rs index a49f8105e26..c272d31b67e 100644 --- a/litellm-rust/crates/core/src/ocr/adapters/reducto/v3.rs +++ b/litellm-rust/crates/core/src/ocr/adapters/reducto/v3.rs @@ -27,7 +27,7 @@ impl OcrAdapter for ReductoV3Adapter { } = _prepare_ocr_request::(request)?; let headers = super::validate_environment(&request.connection, &credential_env)?; let url = super::get_complete_url(request.connection.api_base.as_deref(), "parse")?; - let document = guardrail_document(request, &url).await?; + let (document, headers) = guardrail_document(request, &url, &headers).await?; let document = super::prepare_document(client, document, &request.connection, &headers).await?; let body = reducto::transform_v3_ocr_request(&request.model, document, ¶ms)?; diff --git a/litellm-rust/crates/core/src/ocr/adapters/vertex/deepseek.rs b/litellm-rust/crates/core/src/ocr/adapters/vertex/deepseek.rs index ef188f8b9ac..d16b3e7f386 100644 --- a/litellm-rust/crates/core/src/ocr/adapters/vertex/deepseek.rs +++ b/litellm-rust/crates/core/src/ocr/adapters/vertex/deepseek.rs @@ -57,9 +57,15 @@ impl OcrAdapter for VertexDeepSeekAdapter { let document = request.document.clone(); let body = deepseek::transform_ocr_request(&provider_model(&request.model), document, ¶ms)?; - transform_request_body(client, request, &url, &authentication.headers, body, |_| { - Ok(()) - }) + transform_request_body( + client, + request, + &url, + &authentication.headers, + false, + body, + |_| Ok(()), + ) .await } diff --git a/litellm-rust/crates/core/src/ocr/adapters/vertex/mistral.rs b/litellm-rust/crates/core/src/ocr/adapters/vertex/mistral.rs index f3335bf497c..88c61725cee 100644 --- a/litellm-rust/crates/core/src/ocr/adapters/vertex/mistral.rs +++ b/litellm-rust/crates/core/src/ocr/adapters/vertex/mistral.rs @@ -54,6 +54,8 @@ impl OcrAdapter for VertexMistralAdapter { &location, &request.model, )?; + let retains_document = !request.document.source().starts_with("http://") + && !request.document.source().starts_with("https://"); let document = inline_remote_document( client.document_fetcher(), request.document.clone(), @@ -66,6 +68,7 @@ impl OcrAdapter for VertexMistralAdapter { request, &url, &authentication.headers, + retains_document, body, |body| validate_inline_document(&body.document), ) diff --git a/litellm-rust/crates/core/src/ocr/client.rs b/litellm-rust/crates/core/src/ocr/client.rs index ab2d098d0bb..394ca778d2f 100644 --- a/litellm-rust/crates/core/src/ocr/client.rs +++ b/litellm-rust/crates/core/src/ocr/client.rs @@ -1,10 +1,10 @@ use std::sync::OnceLock; use std::time::Duration; +use bytes::{Bytes, BytesMut}; use serde::de::DeserializeOwned; -use super::error::OcrError; -use super::handler::perform_ocr_request; +use super::error::{OcrError, OcrResponseError}; use super::types::{LiteLLMOcrRequest, LiteLLMOcrResponse}; use super::wire::{DecodedOcrResponse, decode_response}; use crate::Error; @@ -32,6 +32,10 @@ impl OcrClient { }) } + pub fn shared() -> Result { + shared_client() + } + #[tracing::instrument( name = "ocr", target = "litellm::function_trace", @@ -39,7 +43,34 @@ impl OcrClient { skip_all )] pub async fn perform(&self, request: LiteLLMOcrRequest) -> Result { - perform_ocr_request(self, request).await + 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(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(|| { + Error::InvalidRequest("OCR request was already projected".into()) + })?), + false, + )))) + } + OcrCallStep::Host(operation) => result = Some(host.invoke(operation).await), + OcrCallStep::Complete(response) => return Ok(response), + } + } } pub(crate) fn provider_http(&self) -> &reqwest::Client { @@ -77,7 +108,7 @@ fn no_redirect_http() -> Result { .map_err(TransportError::from) } -pub async fn ocr(request: LiteLLMOcrRequest) -> Result { +pub(crate) fn shared_client() -> Result { static CLIENT: OnceLock> = OnceLock::new(); let client = CLIENT .get_or_init(|| { @@ -88,18 +119,50 @@ pub async fn ocr(request: LiteLLMOcrRequest) -> Result Result { + shared_client()?.perform(request).await } pub async fn read_json_response( response: reqwest::Response, native: bool, + max_response_bytes: usize, ) -> Result, OcrError> { + let bytes = read_response_bytes(response, max_response_bytes).await?; + Ok(decode_response(&bytes, native)?) +} + +pub(crate) async fn read_response_bytes( + mut response: reqwest::Response, + max_response_bytes: usize, +) -> Result { let status = response.status(); - let bytes = response - .bytes() - .await - .map_err(crate::error::TransportError::from)?; + let limit = if status.is_success() { + max_response_bytes + } else { + max_response_bytes.min(4 * (crate::constants::UPSTREAM_ERROR_BODY_MAX_CHARS + 1)) + }; + if status.is_success() + && response + .content_length() + .is_some_and(|length| length > limit as u64) + { + return Err(OcrResponseError::TooLarge { limit }.into()); + } + let mut bytes = BytesMut::new(); + while let Some(chunk) = response.chunk().await.map_err(transport_error)? { + let remaining = limit.saturating_sub(bytes.len()); + if status.is_success() && chunk.len() > remaining { + return Err(OcrResponseError::TooLarge { limit }.into()); + } + bytes.extend_from_slice(&chunk[..chunk.len().min(remaining)]); + if !status.is_success() && bytes.len() == limit { + break; + } + } if !status.is_success() { return Err(crate::error::TransportError::Http { status: status.as_u16(), @@ -107,5 +170,41 @@ pub async fn read_json_response( } .into()); } - Ok(decode_response(&bytes, native)?) + Ok(bytes.freeze()) +} + +pub(crate) fn transport_error(error: reqwest::Error) -> Error { + if error.is_timeout() { + return Error::Http { + status: 408, + body: "OCR request timed out".into(), + }; + } + crate::error::TransportError::from(error).into() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn request_timeout_has_an_http_408_status() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let _connection = listener.accept().await.unwrap(); + tokio::time::sleep(Duration::from_secs(1)).await; + }); + let error = reqwest::Client::new() + .get(format!("http://{address}")) + .timeout(Duration::from_millis(10)) + .send() + .await + .unwrap_err(); + assert!(matches!( + transport_error(error), + Error::Http { status: 408, .. } + )); + server.abort(); + } } diff --git a/litellm-rust/crates/core/src/ocr/codecs/cohere.rs b/litellm-rust/crates/core/src/ocr/codecs/cohere.rs new file mode 100644 index 00000000000..649432f39d3 --- /dev/null +++ b/litellm-rust/crates/core/src/ocr/codecs/cohere.rs @@ -0,0 +1,254 @@ +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; + +use crate::ocr::document::InlineDocument; +use crate::ocr::error::{OcrRequestError, OcrResponseError}; +use crate::ocr::types::{LiteLLMOcrResponse, OcrDocument}; + +#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum OutputFormat { + #[default] + Markdown, + Blocks, +} + +#[derive(Deserialize)] +pub(crate) struct CohereParams { + #[serde(default)] + pub output_format: OutputFormat, +} + +#[derive(Deserialize, Serialize)] +pub(crate) struct CohereRequest { + pub model: String, + pub document: OcrDocument, + pub output_format: OutputFormat, +} + +pub(crate) fn validate_document(document: &OcrDocument) -> Result<(), OcrRequestError> { + let OcrDocument::ImageUrl { image_url, .. } = document else { + return Err(OcrRequestError::CohereImageOnly); + }; + if image_url.is_empty() { + return Err(OcrRequestError::CohereImageOnly); + } + if let Some(inline) = InlineDocument::parse(image_url)? { + if !inline.mime_type().type_.eq_ignore_ascii_case("image") { + return Err(OcrRequestError::CohereImageOnly); + } + inline.decode(crate::constants::OCR_INLINE_MAX_BYTES)?; + } + Ok(()) +} + +#[derive(Deserialize)] +pub(crate) struct CohereResponse { + #[serde(default)] + pages: Vec, + meta: Option, +} + +#[derive(Deserialize)] +struct CoherePage { + index: Option, + markdown: Option, + blocks: Option>>, +} + +#[derive(Deserialize)] +struct CohereMarkdown { + #[serde(default)] + content: String, + images: Option>>, +} + +#[derive(Deserialize)] +struct CohereMeta { + billed_units: Option, +} + +#[derive(Deserialize)] +struct CohereBilledUnits { + pages: Option, +} + +pub(crate) fn transform_response( + model: &str, + response: CohereResponse, +) -> Result { + let pages_processed = response + .meta + .and_then(|meta| meta.billed_units) + .and_then(|units| units.pages) + .map(Ok) + .unwrap_or_else(|| { + i64::try_from(response.pages.len()).map_err(|_| OcrResponseError::NumericRange("pages")) + })?; + let pages = response + .pages + .into_iter() + .enumerate() + .map(|(position, page)| { + let index = page.index.map(Ok).unwrap_or_else(|| { + i64::try_from(position).map_err(|_| OcrResponseError::NumericRange("page index")) + })?; + let (content, images) = page + .markdown + .map(|markdown| { + let images = + markdown + .images + .filter(|images| !images.is_empty()) + .map(|images| { + images + .into_iter() + .map(|mut image| { + if let Some(Value::Object(bbox)) = + image.get("bounding_box").cloned() + { + image.insert("bbox".into(), Value::Object(bbox)); + } + Value::Object(image) + }) + .collect::>() + }); + (markdown.content, images) + }) + .unwrap_or_default(); + let mut normalized = json!({"index": index, "markdown": content, "images": images}); + if let Some(blocks) = page.blocks { + normalized["blocks"] = json!(blocks); + } + Ok(normalized) + }) + .collect::, OcrResponseError>>()?; + Ok(LiteLLMOcrResponse { + pages, + model: model.into(), + document_annotation: None, + usage_info: Some(json!({"pages_processed": pages_processed})), + object: "ocr".into(), + extra_fields: Map::new(), + provider_native_response: None, + }) +} + +pub(crate) fn transform_request( + model: &str, + document: OcrDocument, + params: CohereParams, +) -> Result { + validate_document(&document)?; + Ok(CohereRequest { + model: model.into(), + document, + output_format: params.output_format, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn response_normalizes_markdown_images_blocks_and_billed_pages() { + let response = serde_json::from_value(json!({ + "pages": [ + { + "type":"markdown", + "index":4, + "markdown":{ + "content":"receipt", + "images":[{ + "id":"image", + "bounding_box":{"top_left_x":1,"bottom_right_x":48}, + "bounding_box_normalized":{"top_left_x":0.04,"bottom_right_x":0.15}, + "description":"scan", + "category":"logo" + }] + } + }, + {"type":"blocks","blocks":[{"type":"text","text":{"content":"total"}}]} + ], + "meta":{"api_version":{"version":"2"},"billed_units":{"pages":3}} + })) + .unwrap(); + let normalized = transform_response("parse-v5.0", response).unwrap(); + assert_eq!(normalized.pages[0]["index"], 4); + assert_eq!(normalized.pages[0]["markdown"], "receipt"); + assert_eq!(normalized.pages[0]["images"][0]["bbox"]["top_left_x"], 1); + assert_eq!( + normalized.pages[0]["images"][0]["bounding_box_normalized"]["bottom_right_x"], + 0.15 + ); + assert_eq!(normalized.pages[0]["images"][0]["description"], "scan"); + assert_eq!(normalized.pages[0]["images"][0]["category"], "logo"); + assert_eq!(normalized.pages[1]["index"], 1); + assert_eq!(normalized.pages[1]["markdown"], ""); + assert_eq!(normalized.pages[1]["blocks"][0]["text"]["content"], "total"); + assert_eq!(normalized.usage_info.unwrap()["pages_processed"], 3); + } + + #[test] + fn response_defaults_and_invalid_fields() { + for value in [ + json!({}), + json!({"meta":null}), + json!({"pages":[],"meta":{"billed_units":null}}), + ] { + let normalized = + transform_response("parse", serde_json::from_value(value).unwrap()).unwrap(); + assert!(normalized.pages.is_empty()); + assert_eq!(normalized.usage_info.unwrap()["pages_processed"], 0); + } + for value in [ + json!({"pages":null}), + json!({"pages":[{"markdown":"text"}]}), + json!({"pages":[{"index":"bad"}]}), + ] { + assert!(serde_json::from_value::(value).is_err()); + } + let normalized = transform_response( + "parse", + serde_json::from_value(json!({"pages":[{"markdown":null}]})).unwrap(), + ) + .unwrap(); + assert_eq!(normalized.usage_info.unwrap()["pages_processed"], 1); + assert!(normalized.pages[0]["images"].is_null()); + } + + #[test] + fn request_requires_image_and_supported_output_format() { + for value in [ + json!({"type":"document_url","document_url":"https://example.com/a.pdf"}), + json!({"type":"image_url","image_url":""}), + json!({"type":"image_url","image_url":"data:application/pdf;base64,YQ=="}), + ] { + assert_eq!( + validate_document(&serde_json::from_value(value).unwrap()), + Err(OcrRequestError::CohereImageOnly) + ); + } + assert!(serde_json::from_value::(json!({"output_format":"html"})).is_err()); + for format in ["markdown", "blocks"] { + assert!( + serde_json::from_value::(json!({"output_format":format})).is_ok() + ); + } + let request = transform_request( + "parse-v5.0", + serde_json::from_value(json!({ + "type":"image_url", + "image_url":"https://example.com/image.png" + })) + .unwrap(), + serde_json::from_value(json!({})).unwrap(), + ) + .unwrap(); + assert_eq!( + serde_json::to_value(request).unwrap()["output_format"], + "markdown" + ); + } +} diff --git a/litellm-rust/crates/core/src/ocr/codecs/deepseek/transformation.rs b/litellm-rust/crates/core/src/ocr/codecs/deepseek/transformation.rs index 98cfc0db78d..7e8ce63b379 100644 --- a/litellm-rust/crates/core/src/ocr/codecs/deepseek/transformation.rs +++ b/litellm-rust/crates/core/src/ocr/codecs/deepseek/transformation.rs @@ -12,13 +12,17 @@ pub(crate) fn transform_ocr_request( params: &DeepSeekOcrParams, ) -> Result { if document.source().is_empty() { - return Err(OcrRequestError::MissingField("document URL")); + return Err(OcrRequestError::MissingDocumentUrl); } + let content = OcrDocument::ImageUrl { + image_url: document.source().to_string(), + extra_fields: serde_json::Map::new(), + }; Ok(DeepSeekOcrRequest { model: provider_model.to_string(), messages: vec![DeepSeekOcrMessage { role: UserRole::User, - content: vec![document], + content: vec![content], }], params: params.clone(), }) diff --git a/litellm-rust/crates/core/src/ocr/codecs/document_intelligence/params.rs b/litellm-rust/crates/core/src/ocr/codecs/document_intelligence/params.rs index 85d1dafa542..9389f93b8e3 100644 --- a/litellm-rust/crates/core/src/ocr/codecs/document_intelligence/params.rs +++ b/litellm-rust/crates/core/src/ocr/codecs/document_intelligence/params.rs @@ -163,6 +163,30 @@ mod tests { ); } + #[rstest] + #[case(json!([0, 1, 2]), Some("1,2,3"))] + #[case(json!([2, 0, 0, 1]), Some("1,2,3"))] + #[case(json!([]), None)] + #[case(json!("3-9"), Some("3-9"))] + #[case(json!("1-3, 5"), Some("1-3,5"))] + #[case(json!(["1", "3-5"]), Some("1,3-5"))] + fn page_mapping_matches_python(#[case] input: Value, #[case] expected: Option<&str>) { + assert_eq!( + map(json!({"pages": input})).unwrap().pages.as_deref(), + expected + ); + } + + #[rstest] + #[case(json!("a,b"))] + #[case(json!([-1]))] + #[case(json!([true, false]))] + #[case(json!([1, "2"]))] + #[case(json!(5))] + fn invalid_page_mapping_matches_python(#[case] input: Value) { + assert!(map(json!({"pages": input})).is_err()); + } + #[rstest] #[case(json!(["keyValuePairs"]), "keyValuePairs")] #[case(json!(["keyValuePairs", "languages"]), "keyValuePairs,languages")] diff --git a/litellm-rust/crates/core/src/ocr/codecs/document_intelligence/transformation.rs b/litellm-rust/crates/core/src/ocr/codecs/document_intelligence/transformation.rs index 2b848fcfb7a..f76a7c2b232 100644 --- a/litellm-rust/crates/core/src/ocr/codecs/document_intelligence/transformation.rs +++ b/litellm-rust/crates/core/src/ocr/codecs/document_intelligence/transformation.rs @@ -13,7 +13,7 @@ pub(crate) fn transform_ocr_request( ) -> Result { let source = document.source(); if source.is_empty() { - return Err(OcrRequestError::MissingField("document URL")); + return Err(OcrRequestError::MissingDocumentUrl); } Ok(if let Some(document) = InlineDocument::parse(source)? { DocumentIntelligenceRequest::Base64Source( @@ -46,10 +46,7 @@ pub(crate) fn transform_ocr_response( let mut extra_fields = Map::new(); extra_fields.insert("content".into(), option_value(result.content)); extra_fields.insert("tables".into(), option_value(result.tables)); - extra_fields.insert( - "key_value_pairs".into(), - option_value(result.key_value_pairs), - ); + extra_fields.insert("keyValuePairs".into(), option_value(result.key_value_pairs)); Ok(LiteLLMOcrResponse { pages, model: model.into(), diff --git a/litellm-rust/crates/core/src/ocr/codecs/mistral/transformation.rs b/litellm-rust/crates/core/src/ocr/codecs/mistral/transformation.rs index 5bd7e555a1e..e60f1f5d3d6 100644 --- a/litellm-rust/crates/core/src/ocr/codecs/mistral/transformation.rs +++ b/litellm-rust/crates/core/src/ocr/codecs/mistral/transformation.rs @@ -114,6 +114,7 @@ mod tests { #[rstest] #[case("table_format", json!("html"))] #[case("confidence_scores_granularity", json!("word"))] + #[case("confidence_scores_granularity", json!("block"))] #[case("document_annotation_prompt", json!("extract"))] #[case("include_blocks", json!(true))] #[case("id", json!("req-123"))] @@ -133,6 +134,7 @@ mod tests { #[rstest] #[case("pages", json!([0, 2]))] + #[case("pages", json!("0,2-4"))] #[case("include_image_base64", json!(true))] #[case("image_limit", json!(2))] #[case("image_min_size", json!(100))] @@ -196,8 +198,16 @@ mod tests { #[rstest] fn transform_ocr_response_preserves_blocks_and_confidence_scores() { let response: MistralOcrResponse = serde_json::from_value(json!({ - "pages":[{"index":0,"markdown":"hello","blocks":[{"type":"title"}],"confidence_scores":{"mean":0.99}}], + "pages":[{ + "index":0, + "markdown":"hello", + "images":[{"id":"img-0","image_base64":"data:image/png;base64,AA=="}], + "dimensions":{"width":612,"height":792,"dpi":72}, + "blocks":[{"type":"title","bbox":{"x":1},"confidence_scores":{"mean":0.98}}], + "confidence_scores":{"average_page_confidence_score":0.99,"minimum_page_confidence_score":0.97} + }], "model":"returned-model", + "document_annotation":"{\"language\":\"en\"}", "usage_info":{"pages_processed":1} })) .unwrap(); @@ -205,7 +215,20 @@ mod tests { .unwrap() .into_json(); assert_eq!(result["pages"][0]["blocks"][0]["type"], "title"); - assert_eq!(result["pages"][0]["confidence_scores"]["mean"], 0.99); + assert_eq!(result["pages"][0]["blocks"][0]["bbox"]["x"], 1); + assert_eq!( + result["pages"][0]["blocks"][0]["confidence_scores"]["mean"], + 0.98 + ); + assert_eq!( + result["pages"][0]["confidence_scores"]["average_page_confidence_score"], + 0.99 + ); + assert_eq!(result["pages"][0]["images"][0]["id"], "img-0"); + assert_eq!(result["pages"][0]["dimensions"]["dpi"], 72); + assert_eq!(result["model"], "returned-model"); + assert_eq!(result["document_annotation"], "{\"language\":\"en\"}"); + assert_eq!(result["usage_info"]["pages_processed"], 1); } #[rstest] diff --git a/litellm-rust/crates/core/src/ocr/codecs/mistral/types.rs b/litellm-rust/crates/core/src/ocr/codecs/mistral/types.rs index 0e601cd8319..e0bc8a267d2 100644 --- a/litellm-rust/crates/core/src/ocr/codecs/mistral/types.rs +++ b/litellm-rust/crates/core/src/ocr/codecs/mistral/types.rs @@ -3,10 +3,17 @@ use serde_json::{Map, Value}; use crate::ocr::types::OcrDocument; +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub(crate) enum MistralOcrPages { + Range(String), + Indices(Vec), +} + #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub(crate) struct MistralOcrParams { #[serde(skip_serializing_if = "Option::is_none")] - pub pages: Option>, + pub pages: Option, #[serde(skip_serializing_if = "Option::is_none")] pub include_image_base64: Option, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/litellm-rust/crates/core/src/ocr/codecs/mod.rs b/litellm-rust/crates/core/src/ocr/codecs/mod.rs index 7c752749901..639b985b9ae 100644 --- a/litellm-rust/crates/core/src/ocr/codecs/mod.rs +++ b/litellm-rust/crates/core/src/ocr/codecs/mod.rs @@ -1,3 +1,4 @@ +pub(crate) mod cohere; pub(crate) mod deepseek; pub(crate) mod document_intelligence; pub(crate) mod mistral; diff --git a/litellm-rust/crates/core/src/ocr/document.rs b/litellm-rust/crates/core/src/ocr/document.rs index e89b1c5c569..82a32ac1ab5 100644 --- a/litellm-rust/crates/core/src/ocr/document.rs +++ b/litellm-rust/crates/core/src/ocr/document.rs @@ -2,13 +2,90 @@ use base64::{Engine, engine::general_purpose::STANDARD}; use data_url::mime::Mime; use data_url::{DataUrl, DataUrlError, forgiving_base64::DecodeError}; use reqwest::Url; +use serde_json::Map; use super::error::{OcrError, OcrRequestError, OcrResponseError}; use super::types::{OcrConnection, OcrDocument}; -use crate::constants::OCR_MAX_FETCH_REDIRECTS; +use crate::constants::{OCR_INLINE_MAX_BYTES, OCR_MAX_FETCH_REDIRECTS}; use crate::error::{MediaError, TransportError}; use crate::media::{DownloadPolicy, MediaFetcher}; +pub fn encode_file_document( + bytes: &[u8], + file_name: Option<&str>, + mime_type: Option<&str>, +) -> Result { + if bytes.is_empty() { + return Err(OcrRequestError::EmptyFile); + } + if bytes.len() > OCR_INLINE_MAX_BYTES { + return Err(OcrRequestError::InlineDocumentTooLarge); + } + if let Some(value) = mime_type + && !valid_mime_type(value) + { + return Err(OcrRequestError::InvalidMimeType(value.into())); + } + let mime_type = mime_type + .map(str::to_string) + .or_else(|| file_name.map(|name| mime_type_for_name(name).to_string())) + .unwrap_or_else(|| "application/octet-stream".into()); + let source = format!("data:{mime_type};base64,{}", STANDARD.encode(bytes)); + Ok(if mime_type.starts_with("image/") { + OcrDocument::ImageUrl { + image_url: source, + extra_fields: Map::new(), + } + } else { + OcrDocument::DocumentUrl { + document_url: source, + extra_fields: Map::new(), + } + }) +} + +fn valid_mime_type(value: &str) -> bool { + let Some((kind, subtype)) = value.split_once('/') else { + return false; + }; + !kind.is_empty() + && !subtype.is_empty() + && kind.chars().chain(subtype.chars()).all(|character| { + character.is_alphanumeric() || matches!(character, '.' | '+' | '-' | '_') + }) +} + +pub fn mime_type_for_name(name: &str) -> &'static str { + let extension = std::path::Path::new(name) + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default(); + match extension.to_ascii_lowercase().as_str() { + "pdf" => "application/pdf", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "webp" => "image/webp", + "tiff" | "tif" => "image/tiff", + "bmp" => "image/bmp", + _ => mime_guess::from_path(name) + .first_raw() + .unwrap_or("application/octet-stream"), + } +} + +pub fn upload_mime_type<'a>(file_name: Option<&str>, content_type: Option<&'a str>) -> &'a str { + match content_type + .and_then(|value| value.split(';').next()) + .map(str::trim) + { + Some(value) if !value.is_empty() && value != "application/octet-stream" => value, + _ => file_name + .map(mime_type_for_name) + .unwrap_or("application/octet-stream"), + } +} + pub(crate) struct InlineDocument<'a>(DataUrl<'a>); impl<'a> InlineDocument<'a> { @@ -95,9 +172,11 @@ fn map_media_error(error: MediaError) -> OcrError { body: "OCR document download failed".into(), } .into(), - MediaError::Timeout => { - TransportError::Network("OCR document download timed out".into()).into() + MediaError::Timeout => TransportError::Http { + status: 408, + body: "OCR document download timed out".into(), } + .into(), MediaError::Transport(error) => error.into(), } } @@ -114,6 +193,90 @@ mod tests { } } + #[test] + fn file_bytes_are_encoded_with_core_owned_mime_policy() { + assert_eq!( + encode_file_document(b"abc", Some("scan.png"), None).unwrap(), + OcrDocument::ImageUrl { + image_url: "data:image/png;base64,YWJj".into(), + extra_fields: Map::new(), + } + ); + assert_eq!( + encode_file_document(b"abc", None, Some("application/pdf")).unwrap(), + document("data:application/pdf;base64,YWJj") + ); + } + + #[test] + fn file_name_mime_mapping_matches_python() { + for (name, expected) in [ + ("document.pdf", "application/pdf"), + ("image.png", "image/png"), + ("photo.jpg", "image/jpeg"), + ("photo.jpeg", "image/jpeg"), + ("animation.gif", "image/gif"), + ("image.webp", "image/webp"), + ("scan.tiff", "image/tiff"), + ("scan.tif", "image/tiff"), + ("bitmap.bmp", "image/bmp"), + ("DOCUMENT.PDF", "application/pdf"), + ("IMAGE.PNG", "image/png"), + ("file.unknown-extension", "application/octet-stream"), + ] { + assert_eq!(mime_type_for_name(name), expected); + } + } + + #[test] + fn upload_mime_mapping_matches_python() { + assert_eq!( + upload_mime_type(Some("report.pdf"), Some("application/octet-stream")), + "application/pdf" + ); + assert_eq!(upload_mime_type(Some("image.png"), None), "image/png"); + assert_eq!(upload_mime_type(None, None), "application/octet-stream"); + assert_eq!( + upload_mime_type(Some("doc.pdf"), Some("application/pdf; charset=utf-8")), + "application/pdf" + ); + assert_eq!( + upload_mime_type( + Some("img.png"), + Some("image/png; charset=utf-8; boundary=something") + ), + "image/png" + ); + } + + #[test] + fn file_encoding_enforces_decoded_size_limit() { + let bytes = vec![b'a'; OCR_INLINE_MAX_BYTES + 1]; + assert_eq!( + encode_file_document(&bytes, None, None), + Err(OcrRequestError::InlineDocumentTooLarge) + ); + let document = encode_file_document(&bytes[..OCR_INLINE_MAX_BYTES], None, None).unwrap(); + let inline = InlineDocument::parse(document.source()).unwrap().unwrap(); + assert_eq!( + inline.decode(OCR_INLINE_MAX_BYTES).unwrap(), + bytes[..OCR_INLINE_MAX_BYTES] + ); + } + + #[test] + fn file_encoding_rejects_empty_bytes_and_invalid_explicit_mime() { + assert!(encode_file_document(b"", None, None).is_err()); + for mime in [ + "text/plain;bad", + "text/plain/extra", + " text/plain", + "text/plain\n", + ] { + assert!(encode_file_document(b"abc", None, Some(mime)).is_err()); + } + } + #[test] fn decodes_data_urls_and_limits_decoded_size() { for (source, expected) in [ diff --git a/litellm-rust/crates/core/src/ocr/error.rs b/litellm-rust/crates/core/src/ocr/error.rs index 522d059ec48..55ea2cbcdae 100644 --- a/litellm-rust/crates/core/src/ocr/error.rs +++ b/litellm-rust/crates/core/src/ocr/error.rs @@ -4,15 +4,27 @@ use crate::error::TransportError; #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum OcrRequestError { + #[error("File is empty or could not be read")] + EmptyFile, + #[error("Invalid MIME type: {0}")] + InvalidMimeType(String), + #[error( + "Cohere Parse only accepts `image_url` documents; document_url and PDF inputs are not supported" + )] + CohereImageOnly, #[error("Invalid `req_format`. Expected 'native' or 'litellm'.")] RequestFormat, #[error("invalid OCR request field: {path}")] RequestField { path: String }, #[error("missing required field: {0}")] MissingField(&'static str), + #[error("Document URL is required")] + MissingDocumentUrl, #[error("invalid OCR document data URI")] InvalidDataUri, - #[error("Reducto requires a reducto:// id or a data URI")] + #[error( + "Reducto requires a reducto:// id or a data URI; plain HTTP URLs are not supported, upload the file first" + )] ReductoSource, #[error("inline OCR document exceeds the size limit")] InlineDocumentTooLarge, @@ -34,6 +46,8 @@ pub enum OcrRequestError { #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum OcrResponseError { + #[error("OCR response exceeds the size limit of {limit} bytes")] + TooLarge { limit: usize }, #[error("invalid OCR response field: {path}")] ResponseField { path: String }, #[error("OCR response is missing non-empty content")] diff --git a/litellm-rust/crates/core/src/ocr/handler.rs b/litellm-rust/crates/core/src/ocr/handler.rs index 0b04319d966..cd1d538aaa8 100644 --- a/litellm-rust/crates/core/src/ocr/handler.rs +++ b/litellm-rust/crates/core/src/ocr/handler.rs @@ -1,15 +1,17 @@ use super::OcrClient; use super::adapters::OcrAdapter; -use super::hooks::OcrLifecycleHooks; +use super::hooks::{OcrHooks, OcrLifecycleHooks, OcrPostCallRequest}; use super::registry::OcrAdapterKind; use super::types::{LiteLLMOcrRequest, LiteLLMOcrResponse}; use crate::Error; use crate::call_lifecycle::{CallLifecycle, CallLifecycleContext}; +use std::sync::Arc; pub(crate) async fn perform_ocr_request( client: &OcrClient, request: LiteLLMOcrRequest, ) -> Result { + request.response_format()?; let context = CallLifecycleContext::new( "ocr", request.model.clone(), @@ -23,27 +25,71 @@ pub(crate) async fn perform_ocr_request( hooks: request.hooks.clone(), provider_name: context.custom_llm_provider.clone(), }; - CallLifecycle::default().run(context, request, &hooks, |request| async move { - macro_rules! execute_selected_adapter { + CallLifecycle::default() + .run(context, request, &hooks, |request| async move { + PreparedOcrCall::prepare(client.clone(), request) + .await? + .execute() + .await? + .normalize() + }) + .await +} + +pub(crate) struct PreparedOcrCall { + client: OcrClient, + request: LiteLLMOcrRequest, + http: reqwest::Request, +} + +impl PreparedOcrCall { + pub(crate) async fn prepare( + client: OcrClient, + request: LiteLLMOcrRequest, + ) -> Result { + macro_rules! prepare_adapter { ($( $variant:ident, $adapter:ty, $instance:expr, $provider:ident; )+) => { match request.adapter { - $( OcrAdapterKind::$variant => execute_ocr_provider_call(client, &$instance, request).await, )+ + $( OcrAdapterKind::$variant => $instance.prepare_request(&request, &client).await?, )+ } }; } - super::adapters::for_each_ocr_adapter!(execute_selected_adapter) - }).await + let http = super::adapters::for_each_ocr_adapter!(prepare_adapter); + Ok(Self { + client, + request, + http, + }) + } + + pub(crate) async fn execute(self) -> Result { + let url = self.http.url().to_string(); + let headers = request_headers(&self.http)?; + let response = crate::http_utils::http_request(reqwest::RequestBuilder::from_parts( + self.client.provider_http().clone(), + self.http, + )) + .await + .map_err(super::client::transport_error)?; + macro_rules! read_adapter { + ($( $variant:ident, $adapter:ty, $instance:expr, $provider:ident; )+) => { + match self.request.adapter { + $( OcrAdapterKind::$variant => { + let decoded = $instance.read_response(&self.client, response, &url, &headers, &self.request).await?; + Ok(OcrProviderResponse { + request: self.request, + data: OcrProviderData::$variant(decoded), + }) + }, )+ + } + }; + } + super::adapters::for_each_ocr_adapter!(read_adapter) + } } -#[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)] -async fn execute_ocr_provider_call( - client: &OcrClient, - adapter: &A, - request: LiteLLMOcrRequest, -) -> Result { - let provider_request = adapter.prepare_request(&request, client).await?; - let url = provider_request.url().to_string(); - let headers = provider_request +fn request_headers(request: &reqwest::Request) -> Result, Error> { + request .headers() .iter() .map(|(name, value)| { @@ -53,20 +99,41 @@ async fn execute_ocr_provider_call( .map_err(|_| super::error::OcrRequestError::RequestField { path: "headers".into(), }) + .map_err(Error::from) }) - .collect::, _>>()?; - let response = crate::http_utils::http_request(reqwest::RequestBuilder::from_parts( - client.provider_http().clone(), - provider_request, - )) - .await - .map_err(crate::error::TransportError::from)?; - let decoded = adapter - .read_response(client, response, &url, &headers, &request) - .await?; - let response = adapter.transform_ocr_response(&request, decoded.data)?; - Ok(LiteLLMOcrResponse { - provider_native_response: decoded.native, - ..response - }) + .collect() } + +macro_rules! provider_data { + ($( $variant:ident, $adapter:ty, $instance:expr, $provider:ident; )+) => { + enum OcrProviderData { + $( $variant(super::wire::DecodedOcrResponse<<$adapter as OcrAdapter>::ProviderResponse>), )+ + } + + impl OcrProviderResponse { + pub(crate) fn normalize(self) -> Result { + match self.data { + $( OcrProviderData::$variant(decoded) => { + let response = $instance.transform_ocr_response(&self.request, decoded.data)?; + Ok(LiteLLMOcrResponse { provider_native_response: decoded.native, ..response }) + }, )+ + } + } + } + }; +} + +pub(crate) struct OcrProviderResponse { + request: LiteLLMOcrRequest, + data: OcrProviderData, +} + +pub(crate) async fn post_call(hooks: &Arc, bytes: &[u8]) -> Result<(), Error> { + let original_response = serde_json::Value::String(String::from_utf8_lossy(bytes).into_owned()); + hooks + .post_call(OcrPostCallRequest { original_response }) + .await?; + Ok(()) +} + +super::adapters::for_each_ocr_adapter!(provider_data); diff --git a/litellm-rust/crates/core/src/ocr/hooks.rs b/litellm-rust/crates/core/src/ocr/hooks.rs index 7dd3c6bf8b2..3e7507e9ed5 100644 --- a/litellm-rust/crates/core/src/ocr/hooks.rs +++ b/litellm-rust/crates/core/src/ocr/hooks.rs @@ -24,11 +24,19 @@ pub struct OcrDuringCallRequest { pub model: String, pub custom_llm_provider: String, 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 has_guardrails(&self) -> bool { + fn intercepts_requests(&self) -> bool { false } fn pre_call(&self, request: OcrPreCallRequest) -> OcrHookFuture<'_, OcrPreCallRequest> { @@ -40,6 +48,9 @@ pub trait OcrHooks: Send + Sync { ) -> 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, @@ -80,7 +91,7 @@ impl CallLifecycleHooks Self::PreCallFuture<'a> { Box::pin(async move { - if !self.hooks.has_guardrails() { + if !self.hooks.intercepts_requests() { return Ok(request); } let changed = self diff --git a/litellm-rust/crates/core/src/ocr/lifecycle.rs b/litellm-rust/crates/core/src/ocr/lifecycle.rs new file mode 100644 index 00000000000..92c9d4b717c --- /dev/null +++ b/litellm-rust/crates/core/src/ocr/lifecycle.rs @@ -0,0 +1,640 @@ +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +use tokio::sync::{mpsc, oneshot}; + +use super::handler::perform_ocr_request; +use super::hooks::{ + OcrDuringCallRequest, OcrHookFuture, OcrHooks, OcrLogFuture, OcrPostCallRequest, + OcrPreCallRequest, +}; +use super::{LiteLLMOcrRequest, LiteLLMOcrResponse, OcrClient}; +use crate::AuthError; +use crate::Error; +use crate::auth::{ResolvedCredential, TokenFuture, TokenProvider, TokenProviderHandle}; +use crate::call_lifecycle::host::{ + HostCall, HostCallFuture, HostCallStep, HostFailure, HostLifecycle, HostPhase, +}; +use crate::call_lifecycle::{CallLifecycleContext, CallLifecycleTiming}; + +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, + 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>), + 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 Operation = OcrHostOperation; + type Result = OcrHostResult; + type Complete = LiteLLMOcrResponse; + + fn resume( + &mut self, + result: Option, + ) -> HostCallFuture<'_, Self::Operation, Self::Complete> { + Box::pin(OcrCall::resume(self, result)) + } + + fn interrupt( + &mut self, + failure: HostFailure, + ) -> HostCallFuture<'_, Self::Operation, Self::Complete> { + 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>>, + 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, + 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::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(), + }, + ))); + } + request.hooks = Arc::new(ProtocolHooks { + operations: self.operations_tx.clone(), + intercepts_requests, + terminal: self.terminal.clone(), + }); + self.execution = Some(tokio::spawn(async move { + 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.execution = None; + } +} + +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::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::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 1e975c3f521..e29fd6ac572 100644 --- a/litellm-rust/crates/core/src/ocr/mod.rs +++ b/litellm-rust/crates/core/src/ocr/mod.rs @@ -5,12 +5,18 @@ mod document; pub mod error; mod handler; pub mod hooks; +mod lifecycle; mod prepare; mod registry; pub mod types; pub mod wire; pub use client::{OcrClient, ocr}; +pub use document::{encode_file_document, mime_type_for_name, upload_mime_type}; +pub use lifecycle::{ + NativeOutcome, NativeResult, NoopOcrHost, OcrAdmission, OcrCall, OcrCallStep, OcrDecline, + OcrHookHost, OcrHost, OcrHostOperation, OcrHostResult, +}; pub use types::{LiteLLMOcrRequest, LiteLLMOcrResponse, OcrConnection, OcrDocument}; #[cfg(test)] diff --git a/litellm-rust/crates/core/src/ocr/prepare.rs b/litellm-rust/crates/core/src/ocr/prepare.rs index bf6f924088c..9934a1d9a14 100644 --- a/litellm-rust/crates/core/src/ocr/prepare.rs +++ b/litellm-rust/crates/core/src/ocr/prepare.rs @@ -62,34 +62,48 @@ pub(crate) async fn transform_request_body( request: &LiteLLMOcrRequest, url: &str, headers: &[(String, String)], + retains_document: bool, body: B, validate: impl FnOnce(&B) -> Result<(), OcrRequestError>, ) -> Result where B: Serialize + DeserializeOwned, { - let body = if request.hooks.has_guardrails() { + let (body, headers) = if request.hooks.intercepts_requests() { + let body = serde_json::to_value(body).map_err(|_| OcrRequestError::RequestField { + path: "body".into(), + })?; + let retained_fields = request + .optional_params + .keys() + .filter(|name| body.get(*name).is_some()) + .cloned() + .chain(retains_document.then(|| "document".to_string())) + .collect(); let changed = request .hooks .during_call(OcrDuringCallRequest { model: request.model.clone(), custom_llm_provider: request.adapter.provider().as_str().into(), url: url.into(), - body: serde_json::to_value(body).map_err(|_| OcrRequestError::RequestField { - path: "body".into(), - })?, + headers: headers.to_vec(), + body, + retained_fields, }) .await?; let body = OcrWireBody::::decode(changed.body)?; validate(&body.body)?; - body + (body, changed.headers) } else { - OcrWireBody { - body, - extra: Map::new(), - } + ( + OcrWireBody { + body, + extra: Map::new(), + }, + headers.to_vec(), + ) }; - build_http_request(client, request, url, headers, &body) + build_http_request(client, request, url, &headers, &body) } pub(crate) fn build_http_request( @@ -113,9 +127,10 @@ pub(crate) fn build_http_request( pub(crate) async fn guardrail_document( request: &LiteLLMOcrRequest, url: &str, -) -> Result { - if !request.hooks.has_guardrails() { - return Ok(request.document.clone()); + headers: &[(String, String)], +) -> Result<(OcrDocument, Vec<(String, String)>), OcrError> { + if !request.hooks.intercepts_requests() { + return Ok((request.document.clone(), headers.to_vec())); } let changed = request .hooks @@ -123,14 +138,17 @@ pub(crate) async fn guardrail_document( model: request.model.clone(), custom_llm_provider: request.adapter.provider().as_str().into(), url: url.into(), + headers: headers.to_vec(), body: serde_json::to_value(&request.document).map_err(|_| { OcrRequestError::RequestField { path: "document".into(), } })?, + retained_fields: Vec::new(), }) .await?; - super::wire::decode_request_value(changed.body, "guardrail.document").map_err(OcrError::from) + let document = super::wire::decode_request_value(changed.body, "guardrail.document")?; + Ok((document, changed.headers)) } #[derive(Serialize)] diff --git a/litellm-rust/crates/core/src/ocr/registry.rs b/litellm-rust/crates/core/src/ocr/registry.rs index 1b20a91143b..ed7d4fd5cf2 100644 --- a/litellm-rust/crates/core/src/ocr/registry.rs +++ b/litellm-rust/crates/core/src/ocr/registry.rs @@ -23,6 +23,7 @@ super::adapters::for_each_ocr_adapter!(define_adapter_types); #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum OcrProvider { + Cohere, Mistral, AzureAi, Reducto, @@ -32,6 +33,7 @@ pub(crate) enum OcrProvider { impl OcrProvider { pub(crate) const fn as_str(self) -> &'static str { match self { + Self::Cohere => "cohere", Self::Mistral => "mistral", Self::AzureAi => "azure_ai", Self::Reducto => "reducto", @@ -50,6 +52,7 @@ pub(crate) fn resolve_wire_adapter( custom_llm_provider: OcrProvider::Mistral.as_str(), }); let typed_provider = match provider.custom_llm_provider { + "cohere" => OcrProvider::Cohere, "mistral" => OcrProvider::Mistral, "azure_ai" => OcrProvider::AzureAi, "reducto" => OcrProvider::Reducto, @@ -57,10 +60,17 @@ pub(crate) fn resolve_wire_adapter( value => return Err(Error::InvalidProvider(value.to_string())), }; let adapter = match typed_provider { + OcrProvider::Cohere => OcrAdapterKind::Cohere, OcrProvider::Mistral => OcrAdapterKind::Mistral, OcrProvider::AzureAi if is_document_intelligence_model(provider.model) => { OcrAdapterKind::AzureDocumentIntelligence } + OcrProvider::AzureAi + if provider.model.to_ascii_lowercase().contains("cohere") + && provider.model.to_ascii_lowercase().contains("parse") => + { + OcrAdapterKind::AzureCohere + } OcrProvider::AzureAi => OcrAdapterKind::AzureMistral, OcrProvider::Reducto if provider.model.eq_ignore_ascii_case("parse-legacy") => { OcrAdapterKind::ReductoLegacy @@ -68,12 +78,7 @@ pub(crate) fn resolve_wire_adapter( OcrProvider::Reducto if provider.model.eq_ignore_ascii_case("parse-v3") => { OcrAdapterKind::ReductoV3 } - OcrProvider::Reducto => { - return Err(Error::InvalidRequest(format!( - "unsupported Reducto OCR model: {}", - provider.model - ))); - } + OcrProvider::Reducto => OcrAdapterKind::ReductoV3, OcrProvider::VertexAi if provider.model.to_ascii_lowercase().contains("deepseek") => { OcrAdapterKind::VertexDeepSeek } @@ -107,11 +112,10 @@ mod tests { } #[test] - fn unknown_reducto_models_are_rejected() { - assert!(matches!( - resolve_wire_adapter("reducto/future-parse-model", None), - Err(Error::InvalidRequest(_)) - )); + fn unknown_reducto_models_use_the_current_protocol() { + let (model, adapter) = resolve_wire_adapter("reducto/future-parse-model", None).unwrap(); + assert_eq!(model, "future-parse-model"); + assert_eq!(adapter, OcrAdapterKind::ReductoV3); } #[test] diff --git a/litellm-rust/crates/core/src/ocr/types.rs b/litellm-rust/crates/core/src/ocr/types.rs index 06519f86c91..76df8b42806 100644 --- a/litellm-rust/crates/core/src/ocr/types.rs +++ b/litellm-rust/crates/core/src/ocr/types.rs @@ -8,7 +8,7 @@ use serde_json::{Map, Value}; use super::hooks::{NoopOcrHooks, OcrHooks}; use super::registry::{OcrAdapterKind, resolve_wire_adapter}; use crate::Error; -use crate::auth::InputSource; +use crate::auth::{InputSource, TokenProviderHandle}; use crate::constants::OCR_HTTP_TIMEOUT_SECS; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -68,6 +68,7 @@ pub struct OcrConnection { pub extra_headers_source: InputSource, pub timeout: Duration, pub max_download_bytes: u64, + pub max_response_bytes: usize, pub poll_timeout: Duration, } @@ -82,6 +83,7 @@ impl Default for OcrConnection { extra_headers_source: InputSource::Deployment, timeout: Duration::from_secs(OCR_HTTP_TIMEOUT_SECS), max_download_bytes: crate::constants::OCR_DOWNLOAD_MAX_BYTES, + max_response_bytes: crate::constants::OCR_RESPONSE_MAX_BYTES, poll_timeout: Duration::from_secs(crate::constants::OCR_POLL_TIMEOUT_SECS), } } @@ -95,6 +97,7 @@ pub struct LiteLLMOcrRequest { pub litellm_call_id: Option, pub optional_params: Map, pub input_sources: BTreeMap, + pub azure_ad_token_provider: Option, pub(crate) adapter: OcrAdapterKind, } @@ -115,6 +118,7 @@ impl LiteLLMOcrRequest { litellm_call_id: None, optional_params, input_sources: BTreeMap::new(), + azure_ad_token_provider: None, adapter: adapter_kind, }) } @@ -132,6 +136,10 @@ impl LiteLLMOcrRequest { .map(|format| format.unwrap_or_default()) } + pub fn provider_name(&self) -> &'static str { + self.adapter.provider().as_str() + } + pub fn with_host_hooks( self, hooks: Arc, @@ -169,6 +177,47 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn document_variants_preserve_provider_fields_when_rewriting_sources() { + for (value, original, replacement, expected) in [ + ( + json!({ + "type":"document_url", + "document_url":"https://example.com/input.pdf", + "document_name":"input.pdf" + }), + "https://example.com/input.pdf", + "data:application/pdf;base64,AA==", + json!({ + "type":"document_url", + "document_url":"data:application/pdf;base64,AA==", + "document_name":"input.pdf" + }), + ), + ( + json!({ + "type":"image_url", + "image_url":"https://example.com/input.png", + "detail":"high" + }), + "https://example.com/input.png", + "data:image/png;base64,AA==", + json!({ + "type":"image_url", + "image_url":"data:image/png;base64,AA==", + "detail":"high" + }), + ), + ] { + let document: OcrDocument = serde_json::from_value(value).unwrap(); + assert_eq!(document.source(), original); + assert_eq!( + serde_json::to_value(document.with_source(replacement.into())).unwrap(), + expected + ); + } + } + #[test] fn response_serialization_flattens_extra_fields_and_omits_absent_native_response() { let response = LiteLLMOcrResponse { diff --git a/litellm-rust/crates/core/src/ocr/wire.rs b/litellm-rust/crates/core/src/ocr/wire.rs index 34d0a7d7b86..6dc6b34b73d 100644 --- a/litellm-rust/crates/core/src/ocr/wire.rs +++ b/litellm-rust/crates/core/src/ocr/wire.rs @@ -3,7 +3,6 @@ use crate::ocr::error::OcrResponseError; use std::collections::BTreeMap; use std::time::Duration; -use super::hooks::{OcrDuringCallRequest, OcrPreCallRequest}; use super::types::{LiteLLMOcrRequest, OcrConnection, OcrDocument}; use crate::Error; use crate::auth::InputSource; @@ -13,10 +12,58 @@ use serde::{ }; use serde_json::{Map, Value}; +const COMMON_OPTION_FIELDS: &[&str] = &["req_format", "extra_body", "max_response_bytes"]; +const MISTRAL_OPTION_FIELDS: &[&str] = &[ + "pages", + "include_image_base64", + "image_limit", + "image_min_size", + "bbox_annotation_format", + "document_annotation_format", + "document_annotation_prompt", + "extract_header", + "extract_footer", + "table_format", + "confidence_scores_granularity", + "include_blocks", + "id", +]; +const DEEPSEEK_OPTION_FIELDS: &[&str] = + &["stream", "temperature", "max_tokens", "top_p", "n", "stop"]; +const DOCUMENT_INTELLIGENCE_OPTION_FIELDS: &[&str] = &["pages", "features"]; +const REDUCTO_V3_OPTION_FIELDS: &[&str] = &["formatting", "retrieval", "settings"]; +const REDUCTO_LEGACY_OPTION_FIELDS: &[&str] = &["enhance"]; +const AZURE_AUTH_OPTION_FIELDS: &[&str] = &[ + "azure_ad_token", + "tenant_id", + "client_id", + "client_secret", + "azure_scope", + "azure_authority_host", + "azure_credential", + "azure_federated_token_file", + "enable_azure_ad_token_refresh", +]; +const VERTEX_AUTH_OPTION_FIELDS: &[&str] = &[ + "vertex_credentials", + "vertex_ai_credentials", + "vertex_project", + "vertex_ai_project", + "vertex_location", + "vertex_ai_location", +]; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct OptionalParamSpec { + pub name: &'static str, + pub secret: bool, +} + #[derive(Debug)] pub struct DecodedOcrResponse { pub data: T, pub native: Option, + pub text: String, } #[derive(Deserialize)] @@ -39,11 +86,65 @@ pub fn is_supported_request(model: &str, custom_llm_provider: Option<&str>) -> b super::registry::resolve_wire_adapter(model, custom_llm_provider).is_ok() } +pub fn consumed_optional_param_names( + model: &str, + custom_llm_provider: Option<&str>, +) -> Result, Error> { + use super::registry::OcrAdapterKind; + + let (_, adapter) = super::registry::resolve_wire_adapter(model, custom_llm_provider)?; + let provider_fields: &[&str] = match adapter { + OcrAdapterKind::Cohere | OcrAdapterKind::AzureCohere => &["output_format"], + OcrAdapterKind::Mistral | OcrAdapterKind::AzureMistral | OcrAdapterKind::VertexMistral => { + MISTRAL_OPTION_FIELDS + } + OcrAdapterKind::AzureDocumentIntelligence => DOCUMENT_INTELLIGENCE_OPTION_FIELDS, + OcrAdapterKind::ReductoV3 => REDUCTO_V3_OPTION_FIELDS, + OcrAdapterKind::ReductoLegacy => REDUCTO_LEGACY_OPTION_FIELDS, + OcrAdapterKind::VertexDeepSeek => DEEPSEEK_OPTION_FIELDS, + }; + let auth_fields: &[&str] = match adapter { + OcrAdapterKind::AzureMistral + | OcrAdapterKind::AzureDocumentIntelligence + | OcrAdapterKind::AzureCohere => AZURE_AUTH_OPTION_FIELDS, + OcrAdapterKind::VertexMistral | OcrAdapterKind::VertexDeepSeek => VERTEX_AUTH_OPTION_FIELDS, + _ => &[], + }; + Ok(COMMON_OPTION_FIELDS + .iter() + .chain(provider_fields) + .chain(auth_fields) + .copied() + .collect()) +} + +pub fn consumed_optional_params( + model: &str, + custom_llm_provider: Option<&str>, +) -> Result, Error> { + consumed_optional_param_names(model, custom_llm_provider).map(|names| { + names + .into_iter() + .map(|name| OptionalParamSpec { + name, + secret: matches!( + name, + "azure_ad_token" + | "client_secret" + | "azure_federated_token_file" + | "vertex_credentials" + | "vertex_ai_credentials" + ), + }) + .collect() + }) +} + pub fn decode_request(wire: OcrWireRequest) -> Result { let api_key_source = source_for(&wire.input_sources, "api_key"); let api_base_source = source_for(&wire.input_sources, "api_base"); let extra_headers_source = source_for(&wire.input_sources, "extra_headers"); - let document = decode_request_value(wire.document, "document")?; + let document = decode_document(wire.document)?; let headers = wire .extra_headers .unwrap_or_default() @@ -66,11 +167,28 @@ pub fn decode_request(wire: OcrWireRequest) -> Result }) .transpose()?; let defaults = OcrConnection::default(); + let max_response_bytes = wire + .optional_params + .get("max_response_bytes") + .map(|value| { + value + .as_u64() + .and_then(|value| usize::try_from(value).ok()) + .filter(|value| *value > 0 && *value <= defaults.max_response_bytes) + .ok_or_else(|| OcrRequestError::RequestField { + path: "max_response_bytes".into(), + }) + }) + .transpose()? + .unwrap_or(defaults.max_response_bytes); let request = LiteLLMOcrRequest::new( wire.model, document, wire.custom_llm_provider.as_deref(), - wire.optional_params, + wire.optional_params + .into_iter() + .filter(|(name, _)| name != "max_response_bytes") + .collect(), )?; let connection = OcrConnection { api_key: nonblank(wire.api_key), @@ -81,6 +199,7 @@ pub fn decode_request(wire: OcrWireRequest) -> Result extra_headers_source, timeout: timeout.unwrap_or(defaults.timeout), max_download_bytes: defaults.max_download_bytes, + max_response_bytes, poll_timeout: defaults.poll_timeout, }; Ok(LiteLLMOcrRequest { @@ -90,6 +209,16 @@ pub fn decode_request(wire: OcrWireRequest) -> Result }) } +fn decode_document(value: Value) -> Result { + let kind = value.get("type").and_then(Value::as_str); + let missing_url = matches!(kind, Some("document_url")) && value.get("document_url").is_none() + || matches!(kind, Some("image_url")) && value.get("image_url").is_none(); + if missing_url { + return Err(OcrRequestError::MissingDocumentUrl); + } + decode_request_value(value, "document") +} + fn source_for(sources: &BTreeMap, name: &str) -> InputSource { sources.get(name).copied().unwrap_or_default() } @@ -134,38 +263,81 @@ pub fn decode_response( } else { None }; - Ok(DecodedOcrResponse { data, native }) -} - -pub fn decode_pre_call_result( - original: OcrPreCallRequest, - value: Value, -) -> Result { - #[derive(Deserialize)] - struct Changed { - document: OcrDocument, - #[serde(default)] - optional_params: Map, - } - let changed: Changed = decode_request_value(value, "guardrail")?; - Ok(OcrPreCallRequest { - document: changed.document, - optional_params: Value::Object(changed.optional_params), - ..original + Ok(DecodedOcrResponse { + data, + native, + text: String::from_utf8_lossy(bytes).into_owned(), }) } -pub fn decode_during_call_result( - original: OcrDuringCallRequest, - value: Value, -) -> Result { - #[derive(Deserialize)] - struct Changed { - body: Value, +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn option_projection_is_provider_specific_and_excludes_opaque_fields() { + let mistral = consumed_optional_param_names("mistral/model", None).unwrap(); + assert!(mistral.contains(&"pages")); + assert!(mistral.contains(&"req_format")); + assert!(!mistral.contains(&"vertex_project")); + assert!(!mistral.contains(&"opaque_extension")); + + let vertex = consumed_optional_param_names("vertex_ai/deepseek-ocr", None).unwrap(); + assert!(vertex.contains(&"temperature")); + assert!(vertex.contains(&"vertex_credentials")); + assert!(!vertex.contains(&"pages")); + } + + #[test] + fn optional_param_metadata_marks_only_credentials_as_secret() { + let azure = consumed_optional_params("model", Some("azure_ai")).unwrap(); + assert!( + azure + .iter() + .any(|spec| spec.name == "client_secret" && spec.secret) + ); + assert!( + azure + .iter() + .any(|spec| spec.name == "tenant_id" && !spec.secret) + ); + let vertex = consumed_optional_params("deepseek-ocr", Some("vertex_ai")).unwrap(); + assert!( + vertex + .iter() + .any(|spec| spec.name == "vertex_credentials" && spec.secret) + ); + assert!( + vertex + .iter() + .any(|spec| spec.name == "vertex_project" && !spec.secret) + ); + } + + #[test] + fn activation_includes_migrated_providers() { + assert!(is_supported_request("model", Some("mistral"))); + assert!(is_supported_request("pixtral-12b", Some("azure_ai"))); + assert!(is_supported_request( + "documentintelligence/prebuilt-read", + Some("azure_ai") + )); + assert!(is_supported_request("parse-v3", Some("reducto"))); + assert!(is_supported_request("parse-legacy", Some("reducto"))); + assert!(is_supported_request("mistral-ocr", Some("vertex_ai"))); + assert!(is_supported_request("deepseek-ocr", Some("vertex_ai"))); + } + + #[test] + fn missing_document_source_has_a_typed_public_error() { + for document in [ + serde_json::json!({"type": "document_url"}), + serde_json::json!({"type": "image_url"}), + ] { + assert_eq!( + decode_document(document), + Err(OcrRequestError::MissingDocumentUrl) + ); + } } - let changed: Changed = decode_request_value(value, "guardrail")?; - Ok(OcrDuringCallRequest { - body: changed.body, - ..original - }) } diff --git a/litellm-rust/crates/core/src/responses/websocket.rs b/litellm-rust/crates/core/src/responses/websocket.rs index 5d037e9cf1b..34213e5f6c4 100644 --- a/litellm-rust/crates/core/src/responses/websocket.rs +++ b/litellm-rust/crates/core/src/responses/websocket.rs @@ -1,3 +1,21 @@ +use std::collections::HashMap; +use std::io; +use std::sync::{Arc, OnceLock}; +use std::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_tungstenite::{ + Connector, MaybeTlsStream, WebSocketStream, connect_async_tls_with_config, +}; + use crate::Error; use crate::constants::{OPENAI_RESPONSES_DEFAULT_API_BASE, OPENAI_RESPONSES_PATH}; use crate::responses::types::{ResponsesWsEvent, ResponsesWsEventType, ResponsesWsTransformResult}; @@ -125,6 +143,137 @@ pub fn is_terminal_event(event_type: &ResponsesWsEventType) -> bool { ) } +pub type ResponsesUpstreamWs = WebSocketStream>; + +static TLS_CONFIG: OnceLock> = OnceLock::new(); + +fn build_tls_config() -> Result> { + let native = rustls_native_certs::load_native_certs(); + let mut store = RootCertStore::empty(); + let (added, _ignored) = store.add_parsable_certificates(native.certs); + if added == 0 { + return Err(Box::new(tokio_tungstenite::tungstenite::Error::Io( + io::Error::other(format!( + "no usable native root certificates: {:?}", + native.errors + )), + ))); + } + ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider())) + .with_safe_default_protocol_versions() + .map(|builder| builder.with_root_certificates(store).with_no_client_auth()) + .map_err(|error| { + Box::new(tokio_tungstenite::tungstenite::Error::Tls( + TlsError::Rustls(error), + )) + }) +} + +fn tls_config() -> Result, Box> { + if let Some(config) = TLS_CONFIG.get() { + return Ok(Arc::clone(config)); + } + let built = Arc::new(build_tls_config()?); + Ok(Arc::clone(TLS_CONFIG.get_or_init(|| built))) +} + +pub async fn connect_upstream( + request: R, +) -> Result<(ResponsesUpstreamWs, Response), Box> +where + R: IntoClientRequest + Unpin, +{ + let request = request.into_client_request().map_err(Box::new)?; + let connector = match request.uri().scheme_str() { + Some("wss") => Some(Connector::Rustls(tls_config()?)), + _ => None, + }; + connect_async_tls_with_config(request, None, false, connector) + .await + .map_err(Box::new) +} + +#[derive(Clone)] +pub struct ResponsesWebSocketConnection { + socket: Arc>>, +} + +impl ResponsesWebSocketConnection { + pub async fn connect_url( + url: &str, + headers: &HashMap, + timeout: Option, + ) -> Result { + let mut request = url + .into_client_request() + .map_err(|error| Error::Network(error.to_string()))?; + for (name, value) in headers { + let header_name = name + .parse::() + .map_err(|error| Error::InvalidRequest(error.to_string()))?; + let header_value = HeaderValue::from_str(value) + .map_err(|error| Error::InvalidRequest(error.to_string()))?; + request.headers_mut().insert(header_name, header_value); + } + let connect = connect_upstream(request); + let result = match timeout { + Some(timeout) => tokio::time::timeout(timeout, connect) + .await + .map_err(|_| Error::Network("Responses WebSocket connection timed out".into()))?, + None => connect.await, + }; + let (socket, _) = result.map_err(|error| match *error { + tokio_tungstenite::tungstenite::Error::Http(response) => Error::Http { + status: response.status().as_u16(), + body: String::new(), + }, + other => Error::Network(other.to_string()), + })?; + Ok(Self { + socket: Arc::new(Mutex::new(Some(socket))), + }) + } + + pub async fn send_text(&self, text: String) -> Result<(), Error> { + let mut socket = self.socket.lock().await; + let Some(socket) = socket.as_mut() else { + return Err(Error::Network("Responses WebSocket is closed".into())); + }; + socket + .send(Message::Text(text)) + .await + .map_err(|error| Error::Network(error.to_string())) + } + + pub async fn recv_text(&self) -> Result, Error> { + let mut socket = self.socket.lock().await; + let Some(socket) = socket.as_mut() else { + return Ok(None); + }; + match socket.next().await { + Some(Ok(Message::Text(text))) => Ok(Some(text)), + Some(Ok(Message::Binary(bytes))) => String::from_utf8(bytes.to_vec()) + .map(Some) + .map_err(|error| Error::InvalidResponse(error.to_string())), + Some(Ok(Message::Close(_))) | None => Ok(None), + Some(Ok(_)) => Ok(None), + Some(Err(error)) => Err(Error::Network(error.to_string())), + } + } + + pub async fn close(&self) -> Result<(), Error> { + let mut socket = self.socket.lock().await; + if let Some(socket) = socket.as_mut() { + socket + .close(None) + .await + .map_err(|error| Error::Network(error.to_string()))?; + } + *socket = None; + Ok(()) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/litellm-rust/crates/core/tests/azure_ai_ocr.rs b/litellm-rust/crates/core/tests/azure_ai_ocr.rs index d7d532cfef1..b6dc8d90b93 100644 --- a/litellm-rust/crates/core/tests/azure_ai_ocr.rs +++ b/litellm-rust/crates/core/tests/azure_ai_ocr.rs @@ -70,7 +70,7 @@ async fn facade_acquires_supplied_entra_token_for_final_request() { struct ReplaceBodyDocument; impl OcrHooks for ReplaceBodyDocument { - fn has_guardrails(&self) -> bool { + fn intercepts_requests(&self) -> bool { true } 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 e4c81dea5a7..3fca59033cc 100644 --- a/litellm-rust/crates/core/tests/azure_document_intelligence_ocr.rs +++ b/litellm-rust/crates/core/tests/azure_document_intelligence_ocr.rs @@ -1,4 +1,5 @@ use serde_json::{Value, json}; +use std::sync::{Arc, Mutex}; use super::test_support::{MockResponse, mock_server, perform_ocr, wire_request}; use super::wire::{OcrWireRequest, decode_request}; @@ -124,6 +125,14 @@ async fn immediate_response_normalizes_pages_and_preserves_native() { json!({"width":816,"height":1056,"dpi":96}) ); assert_eq!(result.usage_info, Some(json!({"pages_processed":1}))); + let serialized = result.clone().into_json(); + assert_eq!(serialized["content"], "A\n\nB"); + assert_eq!(serialized["tables"], json!([{"cells":[]}])); + assert_eq!( + serialized["keyValuePairs"], + json!([{"key":{"content":"A"}}]) + ); + assert!(serialized.get("key_value_pairs").is_none()); assert_eq!(result.provider_native_response, Some(operation)); } @@ -169,6 +178,55 @@ 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() { + let (base, seen, server) = mock_server(vec![ + MockResponse { + status: 202, + headers: vec![("Operation-Location", "{base}/operation".into())], + body: json!({"submitted": true}), + }, + 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!({})) + }; + + perform_ocr(request).await.unwrap(); + server.await.unwrap(); + assert_eq!(seen.lock().unwrap().len(), 2); +} + #[tokio::test] async fn polling_forwards_bearer_credentials() { let (base, seen, server) = mock_server(vec![ @@ -361,7 +419,7 @@ async fn pre_call_guardrail_receives_caller_pages_before_mapping() { struct RewritePages; impl OcrHooks for RewritePages { - fn has_guardrails(&self) -> bool { + fn intercepts_requests(&self) -> bool { true } diff --git a/litellm-rust/crates/core/tests/deepseek_ocr.rs b/litellm-rust/crates/core/tests/deepseek_ocr.rs index 875fc9e3dc6..4ba39561dcd 100644 --- a/litellm-rust/crates/core/tests/deepseek_ocr.rs +++ b/litellm-rust/crates/core/tests/deepseek_ocr.rs @@ -34,6 +34,28 @@ fn request_mapping_matches_python(#[case] name: &str, #[case] value: Value) { assert!(result.get("ignored").is_none()); } +#[rstest] +#[case(json!({"type":"image_url","image_url":"data:image/png;base64,AA=="}))] +#[case(json!({"type":"document_url","document_url":"data:application/pdf;base64,AA=="}))] +fn request_maps_both_document_types_to_image_content(#[case] document: Value) { + let source = document + .get("image_url") + .or_else(|| document.get("document_url")) + .unwrap() + .clone(); + let request = transform_ocr_request( + "deepseek-ai/deepseek-ocr-maas", + serde_json::from_value(document).unwrap(), + &DeepSeekOcrParams::default(), + ) + .unwrap(); + let result = serde_json::to_value(request).unwrap(); + assert_eq!( + result["messages"][0]["content"][0], + json!({"type":"image_url","image_url":source}) + ); +} + #[rstest] #[case(json!("# hello"), "# hello")] #[case(json!("{broken"), "{broken")] diff --git a/litellm-rust/crates/core/tests/host_lifecycle.rs b/litellm-rust/crates/core/tests/host_lifecycle.rs new file mode 100644 index 00000000000..19fb946afde --- /dev/null +++ b/litellm-rust/crates/core/tests/host_lifecycle.rs @@ -0,0 +1,116 @@ +use crate::Error; +use crate::call_lifecycle::host::{HostFailure, HostLifecycle, HostPhase}; + +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_eq!( + lifecycle.accept(Err(HostFailure::Error(selected.clone()))), + Some(selected) + ); + lifecycle.accept(Ok(())); + for phase in [ + HostPhase::DeploymentFailure, + HostPhase::Failure, + HostPhase::AsyncFailure, + ] { + assert_eq!(lifecycle.phase(), phase); + assert_eq!( + lifecycle.accept(Err(HostFailure::Error(Error::InvalidRequest( + "callback".into() + )))), + 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_eq!( + lifecycle.accept(Err(HostFailure::Cancelled(error.clone()))), + Some(error) + ); + 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 cecd8869741..55f8713d76e 100644 --- a/litellm-rust/crates/core/tests/ocr.rs +++ b/litellm-rust/crates/core/tests/ocr.rs @@ -3,9 +3,16 @@ use std::sync::{Arc, Mutex}; use serde_json::{Value, json}; use super::OcrClient; -use super::hooks::{OcrHookFuture, OcrHooks, OcrLogFuture, OcrPreCallRequest}; +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, +}; use crate::call_lifecycle::{CallLifecycleContext, CallLifecycleTiming}; #[test] @@ -51,7 +58,7 @@ async fn facade_executes_direct_mistral_once() { let result = perform_ocr(wire_request( "mistral/model", &base, - json!({"extract_header":true,"unknown":"ignored"}), + json!({"pages":"0,2-4","extract_header":true,"unknown":"ignored"}), )) .await .unwrap(); @@ -72,6 +79,7 @@ async fn facade_executes_direct_mistral_once() { json!({ "model":"model", "document":{"type":"document_url","document_url":"data:application/pdf;base64,YWJj"}, + "pages":"0,2-4", "extract_header":true }) ); @@ -124,7 +132,7 @@ struct RecordingHooks { } impl OcrHooks for RecordingHooks { - fn has_guardrails(&self) -> bool { + fn intercepts_requests(&self) -> bool { true } @@ -148,6 +156,13 @@ impl OcrHooks for RecordingHooks { }) } + 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, @@ -171,6 +186,38 @@ impl OcrHooks for RecordingHooks { } } +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) }) + } +} + +#[tokio::test] +async fn lifecycle_sends_headers_returned_by_the_typed_during_call_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!({})) + }; + + perform_ocr(request).await.unwrap(); + server.await.unwrap(); + + assert!(seen.lock().unwrap()[0].contains("x-core-callback: edited")); +} + #[tokio::test] async fn lifecycle_orders_hooks_and_emits_one_success() { let (base, seen, server) = mock_server(vec![MockResponse::json(json!({"pages":[]}))]).await; @@ -185,7 +232,10 @@ async fn lifecycle_orders_hooks_and_emits_one_success() { }; perform_ocr(request).await.unwrap(); server.await.unwrap(); - assert_eq!(*events.lock().unwrap(), ["pre", "during", "success"]); + assert_eq!( + *events.lock().unwrap(), + ["pre", "during", "post", "success"] + ); assert_eq!(seen.lock().unwrap().len(), 1); } @@ -227,3 +277,562 @@ async fn upstream_failure_emits_one_terminal_failure() { assert_eq!(*events.lock().unwrap(), ["pre", "during", "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::PreCall(request) => { + phases.push("pre"); + result = Some(OcrHostResult::PreCall(if failure_phase == "pre" { + Err(crate::Error::InvalidRequest("pre failed".into())) + } else { + Ok(request) + })); + } + OcrHostOperation::DuringCall(request) => { + phases.push("during"); + result = Some(OcrHostResult::DuringCall(if failure_phase == "during" { + Err(crate::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::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; + 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, + )))); + } + Ok(OcrCallStep::Host(operation)) => { + if let OcrHostOperation::PostCall(request) = &operation { + post_calls.push(request.original_response.clone()); + } + result = Some(host.invoke(operation).await); + } + Err(error) => break error, + Ok(OcrCallStep::Complete(_)) => panic!("invalid provider response completed"), + } + }; + server.await.unwrap(); + assert!(matches!(error, crate::Error::InvalidResponse(_))); + assert_eq!(seen.lock().unwrap().len(), 1); + assert_eq!(post_calls, [json!(r#"{"pages":"invalid"}"#)]); +} + +#[tokio::test] +async fn direct_native_host_drives_the_same_state_machine() { + let (base, seen, server) = mock_server(vec![MockResponse::json(json!({ + "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, + } + }; + server.await.unwrap(); + assert_eq!(response.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!(matches!( + call.resume(None).await, + Err(crate::Error::InvalidRequest(_)) + )); +} + +#[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::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_eq!(error, selected); + failures.push("sync"); + OcrHostResult::Lifecycle(Err(HostFailure::Error( + crate::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_eq!(error, selected); + 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::Error::InvalidRequest("cancelled".into()); + assert!(matches!( + call.interrupt(HostFailure::Cancelled(selected.clone())).await, + Err(error) if error == selected + )); + assert!( + call.resume(Some(OcrHostResult::Lifecycle(Ok(())))) + .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") + }; + assert!(matches!( + call.resume(None).await.unwrap(), + OcrCallStep::Host(OcrHostOperation::Lifecycle(HostPhase::Setup)) + )); + assert!(call.resume(None).await.is_err()); + assert!(matches!( + call.resume(Some(OcrHostResult::Lifecycle(Ok(())))) + .await + .unwrap(), + OcrCallStep::Host(OcrHostOperation::Lifecycle(HostPhase::Prepare)) + )); +} + +async fn read_bounded_response( + response: Vec, + limit: usize, +) -> Result { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = [0; 4096]; + assert!(socket.read(&mut request).await.unwrap() > 0); + socket.write_all(&response).await.unwrap(); + std::future::pending::<()>().await; + }); + let response = reqwest::Client::new() + .get(format!("http://{address}")) + .send() + .await + .unwrap(); + let result = tokio::time::timeout( + std::time::Duration::from_secs(2), + super::client::read_response_bytes(response, limit), + ) + .await; + server.abort(); + let _ = server.await; + result.expect("bounded reads must finish without waiting for the rest of an oversized body") +} + +#[tokio::test] +async fn response_limit_accepts_exact_size_and_rejects_declared_and_chunked_overflow() { + use super::error::{OcrError, OcrResponseError}; + + for response in [ + "HTTP/1.1 200 OK\r\nContent-Length: 8\r\n\r\nabcdefgh", + "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n4\r\nabcd\r\n4\r\nefgh\r\n0\r\n\r\n", + ] { + assert_eq!( + read_bounded_response(response.as_bytes().to_vec(), 8) + .await + .unwrap(), + "abcdefgh" + ); + } + for response in [ + "HTTP/1.1 200 OK\r\nContent-Length: 9\r\n\r\n", + "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n4\r\nabcd\r\n5\r\nefghi\r\n", + ] { + assert!(matches!( + read_bounded_response(response.as_bytes().to_vec(), 8).await, + Err(OcrError::Response(OcrResponseError::TooLarge { limit: 8 })) + )); + } +} + +#[tokio::test] +async fn oversized_error_retains_http_status_and_bounded_diagnostics_without_draining() { + let prefix = "x".repeat(4 * (crate::constants::UPSTREAM_ERROR_BODY_MAX_CHARS + 1)); + for headers in ["Content-Length: 1000000", "Transfer-Encoding: chunked"] { + let body = if headers.starts_with("Transfer") { + format!("{:x}\r\n{prefix}\r\n", prefix.len()) + } else { + prefix.clone() + }; + let response = format!("HTTP/1.1 429 Too Many Requests\r\n{headers}\r\n\r\n{body}"); + let error = read_bounded_response(response.into_bytes(), 4096) + .await + .unwrap_err(); + match error { + super::error::OcrError::Transport(crate::error::TransportError::Http { + status, + body, + }) => { + assert_eq!(status, 429); + assert_eq!( + body, + format!( + "{}... (truncated)", + "x".repeat(crate::constants::UPSTREAM_ERROR_BODY_MAX_CHARS) + ) + ); + } + error => panic!("unexpected error: {error}"), + } + } +} + +#[test] +fn response_limit_is_validated_and_not_forwarded_to_the_provider() { + let request = wire_request( + "mistral/model", + "http://localhost", + json!({"max_response_bytes": 123}), + ); + assert_eq!(request.connection.max_response_bytes, 123); + assert!(!request.optional_params.contains_key("max_response_bytes")); + for value in [ + json!(0), + json!(-1), + json!(true), + json!("123"), + json!(1.5), + json!(crate::constants::OCR_RESPONSE_MAX_BYTES + 1), + Value::Null, + ] { + let wire = serde_json::from_value(json!({ + "model": "mistral/model", "document": {"type": "document_url", "document_url": "data:application/pdf;base64,YWJj"}, + "optional_params": {"max_response_bytes": value} + })).unwrap(); + let Err(error) = decode_request(wire) else { + panic!("invalid response limit accepted") + }; + assert!(error.to_string().contains("max_response_bytes")); + } +} + +#[derive(Debug)] +struct PendingToken { + entered: Arc, + dropped: Arc, +} + +struct TokenFutureDrop(Arc); + +impl Drop for TokenFutureDrop { + fn drop(&mut self) { + self.0.store(true, std::sync::atomic::Ordering::SeqCst); + } +} + +impl crate::auth::TokenProvider for PendingToken { + fn acquire(&self) -> crate::auth::TokenFuture<'_> { + Box::pin(async move { + let _guard = TokenFutureDrop(self.dropped.clone()); + self.entered.notify_one(); + std::future::pending().await + }) + } +} + +#[tokio::test] +async fn cancellation_waits_for_provider_capture_drop_even_when_acknowledgement_is_cancelled() { + use crate::call_lifecycle::host::HostFailure; + use std::future::Future; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::task::Poll; + + 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 { + connection: super::OcrConnection { + extra_headers: vec![("authorization".into(), "Bearer test-key".into())], + ..request.connection + }, + azure_ad_token_provider: Some(crate::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"), + }); + } + } + } + }).await.unwrap(); + assert!(!dropped.load(Ordering::SeqCst)); + let selected = crate::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(error) if error == selected)); + assert!( + dropped.load(Ordering::SeqCst), + "cancellation returned while provider captures were still alive" + ); + } +} diff --git a/litellm-rust/crates/core/tests/reducto_ocr.rs b/litellm-rust/crates/core/tests/reducto_ocr.rs index 8e86e4713ef..a15e9cae5b5 100644 --- a/litellm-rust/crates/core/tests/reducto_ocr.rs +++ b/litellm-rust/crates/core/tests/reducto_ocr.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use rstest::rstest; use serde_json::{Value, json}; -use super::hooks::{OcrDuringCallRequest, OcrHookFuture, OcrHooks}; +use super::hooks::{OcrDuringCallRequest, OcrHookFuture, OcrHooks, OcrPostCallRequest}; use super::test_support::{MockResponse, mock_server, perform_ocr, wire_request}; fn request_body(request: &str) -> Value { @@ -100,6 +100,42 @@ async fn data_uri_upload_preserves_multipart_headers(#[case] model: &str) { 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() { + 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!({})) + }; + + perform_ocr(request).await.unwrap(); + server.await.unwrap(); + assert_eq!(seen.lock().unwrap().len(), 2); +} + #[rstest] #[case(json!({"file_id":""}))] #[case(json!({}))] @@ -148,9 +184,16 @@ async fn rejects_invalid_document_sources_before_network(#[case] source: &str) { fn response_normalization_groups_blocks_and_distinguishes_null_result() { use crate::ocr::codecs::reducto::{ReductoResponse, transform_ocr_response}; - let raw = json!({"usage":{"num_pages":"2","credits":"3"},"result":{"chunks":[ - {"blocks":[{"content":"B","bbox":{"page":2},"kind":"table"}]}, - {"blocks":[{"content":"A","bbox":{"page":1},"kind":"text"},{"content":"C","bbox":{"page":1}}]} + let raw = json!({"usage":{"num_pages":"2","credits":"3"},"result":{"type":"full","chunks":[ + {"blocks":[{ + "type":"Table", + "content":"B", + "bbox":{"left":0.1,"top":0.2,"width":0.8,"height":0.3,"page":2,"original_page":4}, + "confidence":"high", + "granular_confidence":{"parse_confidence":0.95,"extract_confidence":null}, + "image_url":null + }]}, + {"blocks":[{"content":"A","bbox":{"page":1},"type":"Text"},{"content":"C","bbox":{"page":1}}]} ]}}); let response: ReductoResponse = serde_json::from_value(raw).unwrap(); let normalized = transform_ocr_response("parse-v3", response) @@ -158,7 +201,17 @@ fn response_normalization_groups_blocks_and_distinguishes_null_result() { .into_json(); assert_eq!(normalized["pages"][0]["markdown"], "A\n\nC"); assert_eq!(normalized["pages"][1]["markdown"], "B"); - assert_eq!(normalized["pages"][1]["blocks"][0]["kind"], "table"); + assert_eq!(normalized["pages"][1]["blocks"][0]["type"], "Table"); + assert_eq!( + normalized["pages"][1]["blocks"][0]["bbox"], + json!({"left":0.1,"top":0.2,"width":0.8,"height":0.3,"page":2,"original_page":4}) + ); + assert_eq!(normalized["pages"][1]["blocks"][0]["confidence"], "high"); + assert_eq!( + normalized["pages"][1]["blocks"][0]["granular_confidence"]["parse_confidence"], + 0.95 + ); + assert!(normalized["pages"][1]["blocks"][0]["image_url"].is_null()); assert_eq!(normalized["usage_info"]["pages_processed"], 2); assert_eq!(normalized["usage_info"]["credits"], 3.0); @@ -195,7 +248,7 @@ async fn facade_omits_native_response_by_default_and_preserves_auth_priority() { struct RewriteDocument; impl OcrHooks for RewriteDocument { - fn has_guardrails(&self) -> bool { + fn intercepts_requests(&self) -> bool { true } diff --git a/litellm-rust/crates/core/tests/vertex_ai_deepseek_ocr.rs b/litellm-rust/crates/core/tests/vertex_ai_deepseek_ocr.rs index 6d3061d8f5d..676799eb2fe 100644 --- a/litellm-rust/crates/core/tests/vertex_ai_deepseek_ocr.rs +++ b/litellm-rust/crates/core/tests/vertex_ai_deepseek_ocr.rs @@ -49,7 +49,7 @@ async fn facade_executes_vertex_deepseek_at_the_openai_endpoint() { assert!(body.get("extra_body").is_none()); assert_eq!( body["messages"][0]["content"][0], - json!({"type":"document_url","document_url":"gs://bucket/document.pdf"}) + json!({"type":"image_url","image_url":"gs://bucket/document.pdf"}) ); } diff --git a/litellm-rust/crates/core/tests/workspace_crate_allowlist.rs b/litellm-rust/crates/core/tests/workspace_crate_allowlist.rs deleted file mode 100644 index e7739fe7312..00000000000 --- a/litellm-rust/crates/core/tests/workspace_crate_allowlist.rs +++ /dev/null @@ -1,115 +0,0 @@ -//! Enforcement: the litellm-rust workspace has exactly six crates. -//! -//! `core` (the Rust SDK), `token-counter` (standalone input token counting), -//! `config` (the config-loading boundary), -//! `ai-gateway` (the HTTP/WebSocket host), -//! `python-interop` (domain-neutral PyO3 primitives), and `python-bridge` (the -//! PyO3 cdylib). Adding or removing a crate must be a -//! deliberate act: this test fails until the allowlist here is updated, forcing -//! whoever changes the crate set to justify the new crate per the rule that a -//! crate is a layer needing independent compilation / its own deps / a separate -//! artifact — and to keep `litellm-rust/AGENTS.md` in sync. -//! -//! Std-only (no toml crate): we scan the workspace manifest's `members = [...]` -//! block and the `crates/` directory directly. - -use std::collections::BTreeSet; -use std::fs; -use std::path::{Path, PathBuf}; - -/// The one true crate set. Update BOTH this and `litellm-rust/AGENTS.md` when the -/// workspace legitimately gains or loses a crate. -const EXPECTED_MEMBERS: &[&str] = &[ - "crates/core", - "crates/token-counter", - "crates/config", - "crates/ai-gateway", - "crates/python-interop", - "crates/python-bridge", -]; - -/// The crate subdirectory names that must exist under `crates/`. -const EXPECTED_CRATE_DIRS: &[&str] = &[ - "core", - "token-counter", - "config", - "ai-gateway", - "python-interop", - "python-bridge", -]; - -const MISMATCH: &str = "litellm-rust crate set changed — update this allowlist AND litellm-rust/AGENTS.md, and justify the crate per the rule (crate = layer needing independent compilation / its own deps / a separate artifact)."; - -/// Absolute path to the workspace root (`litellm-rust/`). -fn workspace_root() -> PathBuf { - // CARGO_MANIFEST_DIR is `.../litellm-rust/crates/core`; the workspace root is - // two levels up. - Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../..")) - .canonicalize() - .expect("workspace root should resolve") -} - -/// Parse the `members = [ ... ]` array out of the workspace `[workspace]` table. -/// -/// Minimal hand-rolled scan: find `members`, then collect every double-quoted -/// string up to the closing `]`. Good enough for our fixed manifest shape and -/// keeps this test dependency-free. -fn parse_members(manifest: &str) -> BTreeSet { - let after_members = manifest - .split_once("members") - .map(|(_, rest)| rest) - .expect("workspace manifest should declare members"); - let open = after_members.find('[').expect("members should be an array"); - let close = after_members[open..] - .find(']') - .map(|offset| open + offset) - .expect("members array should be closed"); - let body = &after_members[open + 1..close]; - - let mut members = BTreeSet::new(); - let mut rest = body; - while let Some(start) = rest.find('"') { - let after_quote = &rest[start + 1..]; - let end = after_quote - .find('"') - .expect("opening quote should be matched"); - members.insert(after_quote[..end].to_string()); - rest = &after_quote[end + 1..]; - } - members -} - -/// The crate subdirectory names under `crates/`. -/// -/// A directory counts as a crate only when it holds a `Cargo.toml`; non-crate -/// directories (e.g. docs like `CODING_STANDARDS/`) are ignored so they can live -/// under `crates/` without tripping the crate-proliferation guard. -fn crate_dirs(root: &Path) -> BTreeSet { - fs::read_dir(root.join("crates")) - .expect("crates/ directory should exist") - .filter_map(Result::ok) - .filter(|entry| entry.file_type().map(|ty| ty.is_dir()).unwrap_or(false)) - .filter(|entry| entry.path().join("Cargo.toml").is_file()) - .map(|entry| entry.file_name().to_string_lossy().into_owned()) - .collect() -} - -#[test] -fn workspace_members_match_allowlist() { - let root = workspace_root(); - let manifest = fs::read_to_string(root.join("Cargo.toml")) - .expect("workspace Cargo.toml should be readable"); - - let actual = parse_members(&manifest); - let expected: BTreeSet = EXPECTED_MEMBERS.iter().map(|s| s.to_string()).collect(); - assert_eq!(actual, expected, "{MISMATCH}"); -} - -#[test] -fn crates_directory_matches_allowlist() { - let root = workspace_root(); - - let actual = crate_dirs(&root); - let expected: BTreeSet = EXPECTED_CRATE_DIRS.iter().map(|s| s.to_string()).collect(); - assert_eq!(actual, expected, "{MISMATCH}"); -} diff --git a/litellm-rust/crates/python-bridge/AGENTS.md b/litellm-rust/crates/python-bridge/AGENTS.md index 42282ca4da4..9262617156b 100644 --- a/litellm-rust/crates/python-bridge/AGENTS.md +++ b/litellm-rust/crates/python-bridge/AGENTS.md @@ -1,3 +1,42 @@ -litellm-python-bridge is the PyO3 cdylib that exposes LiteLLM Rust APIs to the Python SDK. Keep API registration, domain dependency wiring, request assembly, and Python exception mapping here. Put domain-neutral Python/Serde conversion and GIL primitives in litellm-python-interop. - -Keep it thin: no business logic, no transforms, no I/O orchestration — just marshal in/out and call the core entrypoint. +- 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 + - 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` + - 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 + - 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 +- 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 +- 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 + - Ship accurate `_native.pyi` declarations and typing markers; distinguish Future-returning bindings from coroutine-returning bindings +- References: [ownership](https://pyo3.rs/v0.29.2/types.html), [GC](https://pyo3.rs/v0.29.2/class/protocols.html#garbage-collector-integration), [exception transfer](https://docs.rs/pyo3/0.29.2/pyo3/struct.PyErr.html#method.into_value), [re-entry](https://pyo3.rs/v0.29.2/class/call.html) + - [GIL policy](https://pyo3.rs/v0.29.2/free-threading.html), [experimental async limits](https://pyo3.rs/v0.29.2/async-await.html), [task conversion](https://docs.rs/pyo3-async-runtimes/0.29.0/pyo3_async_runtimes/fn.into_future_with_locals.html), [native cancellation/delivery](https://docs.rs/pyo3-async-runtimes/0.29.0/pyo3_async_runtimes/tokio/fn.future_into_py.html) + - [performance](https://pyo3.rs/v0.29.2/performance.html), [PyBackedBytes](https://docs.rs/pyo3/0.29.2/pyo3/pybacked/struct.PyBackedBytes.html), [typing](https://pyo3.rs/v0.29.2/python-typing-hints.html) diff --git a/litellm-rust/crates/python-bridge/Cargo.toml b/litellm-rust/crates/python-bridge/Cargo.toml index 337a1e8e5ac..42fad740870 100644 --- a/litellm-rust/crates/python-bridge/Cargo.toml +++ b/litellm-rust/crates/python-bridge/Cargo.toml @@ -17,7 +17,6 @@ panic-test = [] trace-parity = [ "dep:tracing", "litellm-core/observability", - "litellm-ai-gateway/trace-parity", ] [dependencies] @@ -25,7 +24,6 @@ futures-util.workspace = true tracing = { workspace = true, optional = true } litellm-core = { workspace = true, features = ["bedrock-auth"] } litellm-token-counter.workspace = true -litellm-ai-gateway = { workspace = true, default-features = false } litellm-python-interop.workspace = true pyo3.workspace = true pyo3-async-runtimes.workspace = true @@ -35,6 +33,7 @@ tokio = { workspace = true, features = ["sync"] } [dev-dependencies] criterion.workspace = true +rstest.workspace = true tokio-tungstenite.workspace = true tracing.workspace = true diff --git a/litellm-rust/crates/python-bridge/src/auth.rs b/litellm-rust/crates/python-bridge/src/auth.rs new file mode 100644 index 00000000000..8dc0b7aabf0 --- /dev/null +++ b/litellm-rust/crates/python-bridge/src/auth.rs @@ -0,0 +1,194 @@ +use litellm_core::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/errors.rs b/litellm-rust/crates/python-bridge/src/errors.rs index e1f458ea0bc..701c6abb68c 100644 --- a/litellm-rust/crates/python-bridge/src/errors.rs +++ b/litellm-rust/crates/python-bridge/src/errors.rs @@ -22,7 +22,8 @@ pub(crate) fn core_error_to_pyerr(err: Error) -> PyErr { Error::InvalidProvider(_) | Error::InvalidRequest(_) | Error::InvalidType { .. } - | Error::MissingField(_) => PyValueError::new_err(err.to_string()), + | Error::MissingField(_) + | Error::MissingDocumentUrl => PyValueError::new_err(err.to_string()), other => PyRuntimeError::new_err(other.to_string()), } } @@ -41,6 +42,7 @@ pub(crate) fn chat_completions_error_to_pyerr(err: Error) -> PyErr { | Error::InvalidRequest(_) | Error::InvalidType { .. } | Error::MissingField(_) + | Error::MissingDocumentUrl | Error::MissingApiKey { .. } | Error::MissingAzureAiCredentials | Error::MissingAzureDocumentIntelligenceCredentials @@ -49,9 +51,7 @@ pub(crate) fn chat_completions_error_to_pyerr(err: Error) -> PyErr { // Nothing reached the provider, so serving it on Python cannot double // bill and is the only way the caller gets an answer at all. | Error::Connect(_) => RustBridgeDeclined::new_err(err.to_string()), - Error::Http { status, body } => { - RustUpstreamError::new_err((status, format!("{status}: {body}"))) - } + Error::Http { status, body } => RustUpstreamError::new_err((status, body)), Error::Network(message) | Error::InvalidResponse(message) => { RustUpstreamError::new_err((0u16, message)) } @@ -63,41 +63,3 @@ pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { module.add("RustBridgeDeclined", py.get_type::())?; module.add("RustUpstreamError", py.get_type::()) } - -pub(crate) fn ocr_error_to_pyerr(err: Error) -> PyErr { - match err { - Error::MissingField("document_url" | "image_url") => { - PyValueError::new_err("Document URL is required") - } - Error::Http { status, body } => RustUpstreamError::new_err((status, body)), - other => core_error_to_pyerr(other), - } -} - -#[cfg(test)] -mod ocr_error_tests { - use super::*; - - #[test] - fn ocr_errors_preserve_python_validation_and_provider_details() { - Python::initialize(); - Python::attach(|py| { - for field in ["document_url", "image_url"] { - let mapped = ocr_error_to_pyerr(Error::MissingField(field)); - assert!(mapped.is_instance_of::(py)); - assert_eq!(mapped.value(py).to_string(), "Document URL is required"); - } - let mapped = ocr_error_to_pyerr(Error::Http { - status: 429, - body: r#"{"message":"rate limited"}"#.to_string(), - }); - assert!(mapped.is_instance_of::(py)); - let args: (u16, String) = mapped - .value(py) - .getattr("args") - .and_then(|args| args.extract()) - .expect("OCR failures retain status and unprefixed provider message"); - assert_eq!(args, (429, r#"{"message":"rate limited"}"#.to_string())); - }); - } -} diff --git a/litellm-rust/crates/python-bridge/src/execution.rs b/litellm-rust/crates/python-bridge/src/execution.rs index b57197b9ddf..d8dda10068d 100644 --- a/litellm-rust/crates/python-bridge/src/execution.rs +++ b/litellm-rust/crates/python-bridge/src/execution.rs @@ -1,5 +1,7 @@ use std::future::Future; use std::panic::AssertUnwindSafe; +use std::pin::Pin; +use std::task::{Context, Poll, Waker}; use std::time::Duration; use futures_util::FutureExt; @@ -28,6 +30,27 @@ where ) } +pub(crate) fn run_sync_value(py: Python<'_>, future: F) -> PyResult +where + T: Send + 'static, + F: Future> + Send + 'static, +{ + run_sync_value_on(py, pyo3_async_runtimes::tokio::get_runtime(), future) +} + +fn run_sync_value_on(py: Python<'_>, runtime: &Runtime, future: F) -> PyResult +where + T: Send + 'static, + F: Future> + Send + 'static, +{ + if Handle::try_current().is_ok() { + return Err(PyRuntimeError::new_err( + "synchronous native routes cannot run from a Tokio context; use the async route", + )); + } + release_gil(py, move || runtime.block_on(wait_for_sync_result(future)))? +} + fn run_sync_on( py: Python<'_>, runtime: &Runtime, @@ -67,6 +90,32 @@ where }) } +pub(crate) fn run_async_value(py: Python<'_>, future: F) -> PyResult> +where + T: for<'py> IntoPyObject<'py> + Send + 'static, + F: Future> + Send + 'static, +{ + 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> +where + T: Send, + F: Future> + Send, +{ + let result = release_gil(py, || { + let _runtime = pyo3_async_runtimes::tokio::get_runtime().enter(); + std::panic::catch_unwind(AssertUnwindSafe(|| { + future.poll(&mut Context::from_waker(Waker::noop())) + })) + .map_err(panic_to_pyerr) + })?; + match result { + Poll::Ready(result) => result.map(Poll::Ready), + Poll::Pending => Ok(Poll::Pending), + } +} + fn map_core_result(result: Result, map_error: fn(E) -> PyErr) -> PyResult { match result { Ok(value) => Ok(value), @@ -119,11 +168,30 @@ mod tests { use litellm_core::error::Error; use pyo3::panic::PanicException; use pyo3::types::{PyDict, PyModule}; + use rstest::{fixture, rstest}; use serde::Serializer; use tokio::runtime::Builder; use super::*; + struct InitializedPython; + + impl InitializedPython { + fn attach(&self, f: F) -> R + where + F: for<'py> FnOnce(Python<'py>) -> R, + { + Python::attach(f) + } + } + + #[fixture] + #[once] + fn initialized_python() -> InitializedPython { + Python::initialize(); + InitializedPython + } + fn runtime_error(error: Error) -> PyErr { PyRuntimeError::new_err(error.to_string()) } @@ -194,10 +262,84 @@ mod tests { .expect("result should convert") } - #[test] - fn sync_runner_polls_future_on_the_caller_thread() { - Python::initialize(); - Python::attach(|py| { + #[rstest] + fn inline_poll_releases_gil_and_enters_runtime( + #[from(initialized_python)] python: &InitializedPython, + ) { + python.attach(|py| { + let (sender, receiver) = mpsc::sync_channel(1); + let worker = thread::spawn(move || Python::attach(|_| sender.send(()).unwrap())); + let mut future = Box::pin(async move { + receiver.recv_timeout(Duration::from_secs(2)).unwrap(); + Ok(Handle::try_current().is_ok()) + }); + assert_eq!( + poll_async_value(py, future.as_mut()).unwrap(), + Poll::Ready(true) + ); + worker.join().unwrap(); + }); + } + + #[rstest] + fn inline_poll_contains_panics_and_preserves_python_errors( + #[from(initialized_python)] python: &InitializedPython, + ) { + python.attach(|py| { + let mut panicking = Box::pin(poll_fn(|_| -> Poll> { + panic!("inline native panic") + })); + let error = poll_async_value(py, panicking.as_mut()).unwrap_err(); + assert!(error.is_instance_of::(py)); + let original = PyRuntimeError::new_err("inline failure"); + let identity = original.value(py).clone().unbind(); + let mut failing = Box::pin(async move { Err::<(), _>(original) }); + let error = poll_async_value(py, failing.as_mut()).unwrap_err(); + assert!(error.value(py).is(identity.bind(py))); + }); + } + + #[pyfunction] + fn pending_after_inline_poll(py: Python<'_>) -> PyResult> { + let starts = Arc::new(AtomicUsize::new(0)); + let observed = Arc::clone(&starts); + let mut future = Box::pin(async move { + starts.fetch_add(1, Ordering::SeqCst); + tokio::time::sleep(Duration::from_millis(5)).await; + Ok(starts.load(Ordering::SeqCst)) + }); + assert!(poll_async_value(py, future.as_mut())?.is_pending()); + assert_eq!(observed.load(Ordering::SeqCst), 1); + run_async_value(py, future) + } + + #[rstest] + fn inline_pending_future_resumes_on_tokio_without_restarting( + #[from(initialized_python)] python: &InitializedPython, + ) { + python.attach(|py| { + let locals = PyDict::new(py); + locals + .set_item( + "pending", + wrap_pyfunction!(pending_after_inline_poll, py).unwrap(), + ) + .unwrap(); + py.run( + pyo3::ffi::c_str!( + "import asyncio\nasync def exercise():\n assert await asyncio.wait_for(pending(), 2) == 1\nasyncio.run(exercise())" + ), + Some(&locals), + Some(&locals), + ).unwrap(); + }); + } + + #[rstest] + fn sync_runner_polls_future_on_the_caller_thread( + #[from(initialized_python)] python: &InitializedPython, + ) { + python.attach(|py| { let caller_thread = std::thread::current().id(); let result = run_sync( py, @@ -209,10 +351,11 @@ mod tests { }); } - #[test] - fn sync_runner_releases_gil_while_waiting() { - Python::initialize(); - Python::attach(|py| { + #[rstest] + fn sync_runner_releases_gil_while_waiting( + #[from(initialized_python)] python: &InitializedPython, + ) { + python.attach(|py| { let result = run_sync( py, async { @@ -230,16 +373,17 @@ mod tests { }); } - #[test] - fn sync_runner_rejects_calls_from_a_tokio_context() { - Python::initialize(); + #[rstest] + fn sync_runner_rejects_calls_from_a_tokio_context( + #[from(initialized_python)] python: &InitializedPython, + ) { let runtime = Builder::new_current_thread() .enable_all() .build() .expect("runtime should build"); let error = runtime.block_on(async { - Python::attach(|py| { + python.attach(|py| { run_sync::(py, async { Ok(true) }, runtime_error) .expect_err("sync route should reject a nested Tokio runtime") }) @@ -251,14 +395,15 @@ mod tests { ); } - #[test] - fn sync_runner_can_drive_a_current_thread_runtime() { - Python::initialize(); + #[rstest] + fn sync_runner_can_drive_a_current_thread_runtime( + #[from(initialized_python)] python: &InitializedPython, + ) { let runtime = Builder::new_current_thread() .enable_all() .build() .expect("runtime should build"); - Python::attach(|py| { + python.attach(|py| { let result = run_sync_on( py, &runtime, @@ -272,10 +417,9 @@ mod tests { }); } - #[test] - fn sync_runner_maps_a_panicked_future() { - Python::initialize(); - Python::attach(|py| { + #[rstest] + fn sync_runner_maps_a_panicked_future(#[from(initialized_python)] python: &InitializedPython) { + python.attach(|py| { let error = run_sync::( py, poll_fn(|_| -> Poll> { panic!("route future panicked") }), @@ -288,10 +432,11 @@ mod tests { }); } - #[test] - fn sync_runner_maps_a_panicked_error_mapper() { - Python::initialize(); - Python::attach(|py| { + #[rstest] + fn sync_runner_maps_a_panicked_error_mapper( + #[from(initialized_python)] python: &InitializedPython, + ) { + python.attach(|py| { let error = run_sync::( py, async { Err(Error::InvalidRequest("invalid".to_string())) }, @@ -304,10 +449,11 @@ mod tests { }); } - #[test] - fn sync_runner_surfaces_serializer_panics() { - Python::initialize(); - Python::attach(|py| { + #[rstest] + fn sync_runner_surfaces_serializer_panics( + #[from(initialized_python)] python: &InitializedPython, + ) { + python.attach(|py| { let error = run_sync(py, async { Ok(PanickingOutput) }, runtime_error) .expect_err("serializer panic should become a Python exception"); @@ -316,9 +462,10 @@ mod tests { }); } - #[test] - fn sync_runner_supports_concurrent_callers_on_the_shared_runtime() { - Python::initialize(); + #[rstest] + fn sync_runner_supports_concurrent_callers_on_the_shared_runtime( + #[from(initialized_python)] _python: &InitializedPython, + ) { let barrier = Arc::new(tokio::sync::Barrier::new(2)); let callers: Vec<_> = (0..2) .map(|_| { @@ -349,10 +496,11 @@ mod tests { assert_eq!(results, vec![true, true]); } - #[test] - fn async_runner_surfaces_serializer_panics() { - Python::initialize(); - Python::attach(|py| { + #[rstest] + fn async_runner_surfaces_serializer_panics( + #[from(initialized_python)] python: &InitializedPython, + ) { + python.attach(|py| { let module = PyModule::new(py, "runtime").expect("module should be created"); module .add_function( @@ -386,11 +534,12 @@ asyncio.run(exercise()) }); } - #[test] - fn async_result_delivery_does_not_stall_tokio_workers() { - Python::initialize(); + #[rstest] + fn async_result_delivery_does_not_stall_tokio_workers( + #[from(initialized_python)] python: &InitializedPython, + ) { ASYNC_PROBE_COMPLETED.store(0, Ordering::SeqCst); - Python::attach(|py| { + python.attach(|py| { let module = PyModule::new(py, "runtime").expect("module should be created"); for function in [ wrap_pyfunction!(async_runtime_probe, &module).expect("function should wrap"), diff --git a/litellm-rust/crates/python-bridge/src/lib.rs b/litellm-rust/crates/python-bridge/src/lib.rs index cf0450a1b30..12bc57a8931 100644 --- a/litellm-rust/crates/python-bridge/src/lib.rs +++ b/litellm-rust/crates/python-bridge/src/lib.rs @@ -1,14 +1,16 @@ +mod auth; mod constants; mod diagnostics; mod errors; mod execution; #[cfg(feature = "trace-parity")] mod function_trace; +mod lifecycle; mod marshal; mod routes; mod token_counter; -use litellm_ai_gateway::io::responses_ws::ResponsesWebSocketConnection as RustResponsesWebSocketConnection; +use litellm_core::responses::websocket::ResponsesWebSocketConnection as RustResponsesWebSocketConnection; use pyo3::prelude::*; use pyo3::types::PyAny; use serde_json::Value; @@ -64,7 +66,7 @@ impl ResponsesWebSocketConnection { } } -#[pymodule(gil_used = false)] +#[pymodule(gil_used = true)] mod _native { use pyo3::prelude::*; @@ -152,7 +154,6 @@ mod tests { "amessages", "chat_completions", "achat_completions", - "gateway_messages", ] ); } diff --git a/litellm-rust/crates/python-bridge/src/lifecycle/bindings.rs b/litellm-rust/crates/python-bridge/src/lifecycle/bindings.rs new file mode 100644 index 00000000000..06b32b67fd5 --- /dev/null +++ b/litellm-rust/crates/python-bridge/src/lifecycle/bindings.rs @@ -0,0 +1,391 @@ +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/handle.rs b/litellm-rust/crates/python-bridge/src/lifecycle/handle.rs new file mode 100644 index 00000000000..17a480a7225 --- /dev/null +++ b/litellm-rust/crates/python-bridge/src/lifecycle/handle.rs @@ -0,0 +1,139 @@ +use std::panic::{AssertUnwindSafe, catch_unwind}; + +use litellm_python_interop::panic_to_pyerr; +use pyo3::exceptions::{PyBaseException, PyRuntimeError}; +use pyo3::gc::{PyTraverseError, PyVisit}; +use pyo3::prelude::*; + +pub(super) enum ExecutionStep { + Return(Py), + Await(Py), +} + +pub(super) trait ExecutionBody: Send + Sync { + fn resume(&mut self, result: Option>>) -> PyResult; + fn traverse(&self, visit: &PyVisit<'_>) -> Result<(), PyTraverseError>; +} + +enum ExecutionState { + Created(Box), + Running, + Suspended(Box), + Closed, +} + +#[pyclass] +pub(super) struct Execution { + state: ExecutionState, +} + +impl Execution { + pub(super) fn new(body: impl ExecutionBody + 'static) -> Self { + Self { + state: ExecutionState::Created(Box::new(body)), + } + } + + fn advance( + slf: &Bound<'_, Self>, + py: Python<'_>, + result: Option>>, + ) -> PyResult> { + let mut body = { + let mut execution = slf.borrow_mut(); + match (&execution.state, result.is_some()) { + (ExecutionState::Created(_), false) | (ExecutionState::Suspended(_), true) => {} + (ExecutionState::Running, _) => { + return Err(PyRuntimeError::new_err("execution is already running")); + } + (ExecutionState::Closed, _) => { + return Err(PyRuntimeError::new_err("execution is closed")); + } + _ => { + return Err(PyRuntimeError::new_err( + "execution requires start before resume and can only start once", + )); + } + } + match std::mem::replace(&mut execution.state, ExecutionState::Running) { + ExecutionState::Created(body) | ExecutionState::Suspended(body) => body, + _ => unreachable!(), + } + }; + let outcome = catch_unwind(AssertUnwindSafe(|| { + let step = body.resume(result)?; + let (tag, value, suspended) = match step { + ExecutionStep::Await(value) => ("Await", value, true), + ExecutionStep::Return(value) => ("Complete", value, false), + }; + let step = py + .import("litellm.rust_bridge.lifecycle")? + .getattr(tag)? + .call1((value,))? + .unbind(); + Ok((step, suspended)) + })) + .map_err(panic_to_pyerr) + .and_then(|result| result); + match outcome { + Ok((step, true)) if matches!(slf.borrow().state, ExecutionState::Running) => { + slf.borrow_mut().state = ExecutionState::Suspended(body); + Ok(step) + } + outcome => { + slf.borrow_mut().state = ExecutionState::Closed; + drop(body); + outcome.and_then(|(step, suspended)| { + if suspended { + Err(PyRuntimeError::new_err( + "execution was closed while running", + )) + } else { + Ok(step) + } + }) + } + } + } +} + +#[pymethods] +impl Execution { + fn start(slf: &Bound<'_, Self>, py: Python<'_>) -> PyResult> { + Self::advance(slf, py, None) + } + + fn resume_value( + slf: &Bound<'_, Self>, + py: Python<'_>, + value: Py, + ) -> PyResult> { + Self::advance(slf, py, Some(Ok(value))) + } + + fn resume_error( + slf: &Bound<'_, Self>, + py: Python<'_>, + error: Bound<'_, PyBaseException>, + ) -> PyResult> { + Self::advance(slf, py, Some(Err(PyErr::from_value(error.into_any())))) + } + + fn close(slf: &Bound<'_, Self>) { + let state = std::mem::replace(&mut slf.borrow_mut().state, ExecutionState::Closed); + drop(state); + } + + fn __traverse__(&self, visit: PyVisit<'_>) -> Result<(), PyTraverseError> { + match &self.state { + ExecutionState::Created(body) | ExecutionState::Suspended(body) => { + body.traverse(&visit) + } + _ => Ok(()), + } + } + + fn __clear__(slf: &Bound<'_, Self>) { + Self::close(slf); + } +} diff --git a/litellm-rust/crates/python-bridge/src/lifecycle/mod.rs b/litellm-rust/crates/python-bridge/src/lifecycle/mod.rs new file mode 100644 index 00000000000..014564ae89d --- /dev/null +++ b/litellm-rust/crates/python-bridge/src/lifecycle/mod.rs @@ -0,0 +1,1175 @@ +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: litellm_core::Error) -> PyErr; + 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, litellm_core::Error>; +type HostResumeStep = HostStep::Call>, Py>; + +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: Option::Result, HostFailure>>, + ) -> 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 { + let native = litellm_core::Error::InvalidRequest(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_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 Operation = (); + type Result = (); + type Complete = (); + + fn resume( + &mut self, + result: Option, + ) -> HostCallFuture<'_, Self::Operation, Self::Complete> { + Box::pin(async move { + match (self.0, result) { + (false, None) => { + self.0 = true; + Ok(NativeCallStep::Host(())) + } + (true, Some(())) => Ok(NativeCallStep::Complete(())), + _ => Err(litellm_core::Error::InvalidRequest( + "invalid synthetic lifecycle state".into(), + )), + } + }) + } + + fn interrupt( + &mut self, + _: HostFailure, + ) -> HostCallFuture<'_, Self::Operation, Self::Complete> { + 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::Error) -> PyErr { + crate::errors::core_error_to_pyerr(error) + } + + 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| { + 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(); + 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 source = std::ffi::CString::new(include_str!( + "../../../../../litellm/rust_bridge/lifecycle.py" + )) + .unwrap(); + let module = PyModule::from_code( + py, + &source, + pyo3::ffi::c_str!("lifecycle.py"), + pyo3::ffi::c_str!("litellm.rust_bridge.lifecycle"), + ) + .unwrap(); + 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/lifecycle/preparation.rs b/litellm-rust/crates/python-bridge/src/lifecycle/preparation.rs new file mode 100644 index 00000000000..ba4a8bb3739 --- /dev/null +++ b/litellm-rust/crates/python-bridge/src/lifecycle/preparation.rs @@ -0,0 +1,314 @@ +use litellm_core::auth::{credential_default_fields, credential_index}; +use pyo3::prelude::*; +use pyo3::types::{PyDict, PyList}; + +struct CredentialEntry<'py>(Bound<'py, PyAny>); + +impl<'py> CredentialEntry<'py> { + fn name(&self) -> PyResult { + self.0.getattr("credential_name")?.extract() + } + + fn values(&self) -> PyResult> { + Ok(self.0.getattr("credential_values")?.cast_into::()?) + } +} + +pub(super) fn prepare<'py>( + py: Python<'py>, + kwargs: &Bound<'py, PyDict>, + logger: &super::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")? + .getattr("check_limits")? + .call1((&arguments,))?; + Ok(arguments) +} + +fn inherit_credentials( + py: Python<'_>, + litellm: &Bound<'_, PyModule>, + arguments: &Bound<'_, PyDict>, +) -> PyResult<()> { + let Some(requested) = arguments + .get_item("litellm_credential_name")? + .filter(|value| !value.is_none()) + else { + return Ok(()); + }; + if !requested.is_truthy()? { + return Ok(()); + } + let requested: String = requested.extract()?; + let credentials = litellm.getattr("credential_list")?.cast_into::()?; + let names = credentials + .iter() + .map(|credential| CredentialEntry(credential).name()) + .collect::>>()?; + let Some(index) = credential_index(&requested, &names) 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()), + )?; + return Ok(()); + }; + let selected = CredentialEntry(credentials.get_item(index)?); + 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)?; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn eval<'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 + } + + fn inherit(py: Python<'_>, locals: &Bound<'_, PyDict>) -> PyResult<()> { + let litellm = PyModule::new(py, "credential_host")?; + litellm.setattr( + "credential_list", + locals.get_item("credentials").unwrap().unwrap(), + )?; + inherit_credentials( + py, + &litellm, + &locals + .get_item("arguments") + .unwrap() + .unwrap() + .cast_into::()?, + ) + } + + #[test] + fn duplicate_names_select_the_first_entry_without_reading_other_values() { + Python::initialize(); + Python::attach(|py| { + let locals = eval( + py, + c" +accesses = [] +class Credential: + def __init__(self, name, values): + self._name = name + self._values = values + @property + def credential_name(self): + accesses.append(('name', self._name)) + return self._name + @property + def credential_values(self): + accesses.append(('values', self._name)) + return self._values +credentials = [ + Credential('ocr-test', {'api_key': 'first'}), + Credential('other', {'api_key': 'unused'}), + Credential('ocr-test', {'api_key': 'later'}), +] +arguments = {'litellm_credential_name': 'ocr-test'} +", + ); + inherit(py, &locals).unwrap(); + let arguments = locals + .get_item("arguments") + .unwrap() + .unwrap() + .cast_into::() + .unwrap(); + assert_eq!( + arguments + .get_item("api_key") + .unwrap() + .unwrap() + .extract::() + .unwrap(), + "first" + ); + let accesses: Vec<(String, String)> = locals + .get_item("accesses") + .unwrap() + .unwrap() + .extract() + .unwrap(); + assert_eq!( + accesses, + [ + ("name".into(), "ocr-test".into()), + ("name".into(), "other".into()), + ("name".into(), "ocr-test".into()), + ("values".into(), "ocr-test".into()), + ] + ); + }); + } + + #[test] + fn later_invalid_name_still_fails_after_an_earlier_match() { + Python::initialize(); + Python::attach(|py| { + let locals = eval( + py, + c" +failure = LookupError('later name') +class Good: + credential_name = 'ocr-test' + credential_values = {'api_key': 'first'} +class Bad: + @property + def credential_name(self): + raise failure +credentials = [Good(), Bad()] +arguments = {'litellm_credential_name': 'ocr-test'} +", + ); + let error = inherit(py, &locals).unwrap_err(); + assert!( + error + .value(py) + .is(locals.get_item("failure").unwrap().unwrap()) + ); + }); + } + + #[test] + fn selected_values_must_be_a_dictionary_and_property_errors_keep_identity() { + Python::initialize(); + Python::attach(|py| { + let locals = eval( + py, + c" +class Listed: + credential_name = 'ocr-test' + credential_values = ['not-a-dict'] +credentials = [Listed()] +arguments = {'litellm_credential_name': 'ocr-test'} +", + ); + assert!( + inherit(py, &locals) + .unwrap_err() + .is_instance_of::(py) + ); + + let locals = eval( + py, + c" +failure = RuntimeError('values failed') +class Broken: + credential_name = 'ocr-test' + @property + def credential_values(self): + raise failure +credentials = [Broken()] +arguments = {'litellm_credential_name': 'ocr-test'} +", + ); + let error = inherit(py, &locals).unwrap_err(); + assert!( + error + .value(py) + .is(locals.get_item("failure").unwrap().unwrap()) + ); + }); + } + + #[test] + fn explicit_none_is_not_overwritten_and_inherited_objects_keep_identity() { + Python::initialize(); + Python::attach(|py| { + let locals = eval( + py, + c" +opaque = object() +class Credential: + credential_name = 'ocr-test' + credential_values = {'api_key': 'credential-key', 'opaque': opaque} +credentials = [Credential()] +arguments = {'litellm_credential_name': 'ocr-test', 'api_key': None} +", + ); + inherit(py, &locals).unwrap(); + let arguments = locals + .get_item("arguments") + .unwrap() + .unwrap() + .cast_into::() + .unwrap(); + assert!(arguments.get_item("api_key").unwrap().unwrap().is_none()); + assert!( + arguments + .get_item("opaque") + .unwrap() + .unwrap() + .is(locals.get_item("opaque").unwrap().unwrap()) + ); + }); + } + + #[test] + fn selection_rereads_the_list_after_name_properties_run() { + Python::initialize(); + Python::attach(|py| { + let locals = eval( + py, + c" +class First: + @property + def credential_name(self): + credentials[0] = Second() + return 'ocr-test' + credential_values = {'api_key': 'first'} +class Second: + credential_name = 'ocr-test' + credential_values = {'api_key': 'replaced'} +credentials = [First()] +arguments = {'litellm_credential_name': 'ocr-test'} +", + ); + inherit(py, &locals).unwrap(); + let arguments = locals + .get_item("arguments") + .unwrap() + .unwrap() + .cast_into::() + .unwrap(); + assert_eq!( + arguments + .get_item("api_key") + .unwrap() + .unwrap() + .extract::() + .unwrap(), + "replaced" + ); + }); + } + + #[test] + fn falsy_credential_names_return_before_loading_credentials() { + Python::initialize(); + Python::attach(|py| { + let litellm = PyModule::new(py, "credential_host").unwrap(); + for name in [py.None(), py.eval(c"''", None, None).unwrap().unbind()] { + let arguments = PyDict::new(py); + arguments.set_item("litellm_credential_name", name).unwrap(); + inherit_credentials(py, &litellm, &arguments).unwrap(); + } + }); + } +} diff --git a/litellm-rust/crates/python-bridge/src/marshal.rs b/litellm-rust/crates/python-bridge/src/marshal.rs index a14e4b55d82..5f7633a64a0 100644 --- a/litellm-rust/crates/python-bridge/src/marshal.rs +++ b/litellm-rust/crates/python-bridge/src/marshal.rs @@ -1,10 +1,14 @@ -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::time::Duration; use pyo3::exceptions::PyValueError; use pyo3::prelude::*; +use pyo3::types::PyDict; use serde_json::{Map, Value}; +use litellm_core::auth::InputSource; +use litellm_python_interop::from_py_preserving_errors as from_py; + pub(crate) struct RouteOptions { pub(crate) model: String, pub(crate) api_key: Option, @@ -36,18 +40,18 @@ impl RouteOptions { } } -pub(crate) fn required_value( - name: &'static str, - value: Value, - expected: fn(&Value) -> bool, - expected_name: &'static str, -) -> PyResult { - if expected(&value) { - return Ok(value); +pub(crate) fn required_array(name: &'static str, value: Value) -> PyResult> { + match value { + Value::Array(values) => Ok(values), + _ => Err(PyValueError::new_err(format!("{name} must be a list"))), + } +} + +pub(crate) 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"))), } - Err(PyValueError::new_err(format!( - "{name} must be a {expected_name}" - ))) } pub(crate) fn object_or_empty( @@ -55,7 +59,7 @@ pub(crate) fn object_or_empty( value: Option, ) -> PyResult> { match value { - Some(value) => object(name, value), + Some(value) => required_object(name, value), None => Ok(Map::new()), } } @@ -64,14 +68,7 @@ fn optional_object( name: &'static str, value: Option, ) -> PyResult>> { - value.map(|value| object(name, value)).transpose() -} - -fn object(name: &'static str, value: Value) -> PyResult> { - match value { - Value::Object(map) => Ok(map), - _ => Err(PyValueError::new_err(format!("{name} must be a dict"))), - } + value.map(|value| required_object(name, value)).transpose() } pub(crate) fn optional_timeout(timeout_seconds: Option) -> Option { @@ -84,6 +81,72 @@ pub(crate) fn optional_timeout(timeout_seconds: Option) -> Option }) } +pub(crate) fn python_timeout_seconds(py: Python<'_>, timeout: Py) -> PyResult> { + py.import("litellm.rust_bridge.timeouts")? + .getattr("timeout_to_seconds")? + .call1((timeout,))? + .extract() +} + +pub(crate) fn project_optional_fields( + kwargs: &Bound<'_, PyDict>, + names: &[&str], +) -> PyResult> { + names + .iter() + .filter_map(|name| match kwargs.get_item(name) { + Ok(Some(value)) => Some(from_py(&value).map(|value| ((*name).to_string(), value))), + Ok(None) => None, + Err(error) => Some(Err(error)), + }) + .collect() +} + +struct RequestFieldSources<'py> { + body: Option>, + credentials: Option>, +} + +impl<'py> RequestFieldSources<'py> { + fn extract(proxy_request: &Bound<'py, PyAny>) -> PyResult { + let proxy_request = proxy_request.cast::()?; + + let body = proxy_request + .get_item("body_fields")? + .or(proxy_request.get_item("body")?); + + let credentials = proxy_request.get_item("credential_fields")?; + + Ok(Self { body, credentials }) + } + + fn contains(&self, name: &str) -> bool { + self.body + .as_ref() + .is_some_and(|fields| fields.contains(name).unwrap_or(false)) + || self + .credentials + .as_ref() + .is_some_and(|fields| fields.contains(name).unwrap_or(false)) + } +} + +pub(crate) fn request_input_sources<'a>( + kwargs: &Bound<'_, PyDict>, + names: impl Iterator, +) -> PyResult> { + let Some(proxy_request) = kwargs.get_item("proxy_server_request")? else { + return Ok(BTreeMap::new()); + }; + + let sources = RequestFieldSources::extract(&proxy_request)?; + + Ok(names + .filter(|name| sources.contains(name)) + .map(|name| (name.to_string(), InputSource::Request)) + .collect()) +} + pub(crate) fn marshal_headers(headers: Option) -> PyResult> { let value = match headers { Some(headers) => headers, @@ -102,3 +165,199 @@ pub(crate) fn marshal_headers(headers: Option) -> PyResult(py: Python<'py>, source: &std::ffi::CStr) -> Bound<'py, PyDict> { + let locals = PyDict::new(py); + py.run(source, Some(&locals), Some(&locals)).unwrap(); + locals + } + + fn sources( + py: Python<'_>, + proxy: &Bound<'_, PyAny>, + names: &[&str], + ) -> PyResult> { + let kwargs = PyDict::new(py); + kwargs.set_item("proxy_server_request", proxy)?; + request_input_sources(&kwargs, names.iter().copied()) + } + + #[test] + fn required_shapes_preserve_nested_values_and_existing_errors() { + let nested = json!([{"role": "user", "content": [{"type": "text", "text": "hi"}]}]); + assert_eq!( + Value::Array(required_array("messages", nested.clone()).unwrap()), + nested + ); + + let body = json!({"model": "claude", "metadata": {"user": "1"}}); + assert_eq!( + Value::Object(required_object("body", body.clone()).unwrap()), + body + ); + + 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() + ); + } + + #[test] + fn missing_none_and_empty_proxy_metadata_are_distinct() { + Python::initialize(); + Python::attach(|py| { + let kwargs = PyDict::new(py); + assert!( + request_input_sources(&kwargs, ["api_key"].into_iter()) + .unwrap() + .is_empty() + ); + + kwargs.set_item("proxy_server_request", py.None()).unwrap(); + assert!( + request_input_sources(&kwargs, ["api_key"].into_iter()) + .unwrap_err() + .is_instance_of::(py) + ); + + kwargs + .set_item("proxy_server_request", PyDict::new(py)) + .unwrap(); + assert!( + request_input_sources(&kwargs, ["api_key"].into_iter()) + .unwrap() + .is_empty() + ); + }); + } + + #[test] + fn body_fields_win_over_body_and_explicit_none_does_not_fall_back() { + Python::initialize(); + Python::attach(|py| { + let locals = eval( + py, + c" +proxy = {'body_fields': ['api_key'], 'body': ['api_base']} +none_fields = {'body_fields': None, 'body': ['api_key']} +body_only = {'body': ['api_base']} +", + ); + let named = sources( + py, + &locals.get_item("proxy").unwrap().unwrap(), + &["api_key", "api_base"], + ) + .unwrap(); + assert_eq!(named.get("api_key").copied(), Some(InputSource::Request)); + assert!(!named.contains_key("api_base")); + + assert!( + sources( + py, + &locals.get_item("none_fields").unwrap().unwrap(), + &["api_key"], + ) + .unwrap() + .is_empty() + ); + + let body_only = sources( + py, + &locals.get_item("body_only").unwrap().unwrap(), + &["api_base"], + ) + .unwrap(); + assert_eq!( + body_only.get("api_base").copied(), + Some(InputSource::Request) + ); + }); + } + + #[test] + fn body_and_credential_membership_can_mark_request_fields() { + Python::initialize(); + Python::attach(|py| { + let locals = eval( + py, + c" +class Raising: + def __contains__(self, item): + raise RuntimeError('credential membership') +proxy = { + 'body_fields': ['api_key'], + 'credential_fields': Raising(), +} +credentials_only = {'credential_fields': ['extra_headers']} +erroring = {'body_fields': Raising()} +extra = {'body_fields': ['api_key', 'unused']} +", + ); + let skipped = sources( + py, + &locals.get_item("proxy").unwrap().unwrap(), + &["api_key"], + ) + .unwrap(); + assert_eq!(skipped.get("api_key").copied(), Some(InputSource::Request)); + + let credentials = sources( + py, + &locals.get_item("credentials_only").unwrap().unwrap(), + &["extra_headers"], + ) + .unwrap(); + assert_eq!( + credentials.get("extra_headers").copied(), + Some(InputSource::Request) + ); + + assert!( + sources( + py, + &locals.get_item("erroring").unwrap().unwrap(), + &["api_key"], + ) + .unwrap() + .is_empty() + ); + + let requested = sources( + py, + &locals.get_item("extra").unwrap().unwrap(), + &["api_key"], + ) + .unwrap(); + assert_eq!(requested.len(), 1); + assert_eq!( + requested.get("api_key").copied(), + Some(InputSource::Request) + ); + }); + } +} 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 new file mode 100644 index 00000000000..f2997ee278c --- /dev/null +++ b/litellm-rust/crates/python-bridge/src/routes/audio_transcription/mod.rs @@ -0,0 +1,12 @@ +mod value; + +use pyo3::prelude::*; + +pub(super) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + value::register(module) +} + +#[cfg(feature = "trace-parity")] +pub(super) fn register_trace(module: &Bound<'_, PyModule>) -> PyResult<()> { + value::register_trace(module) +} diff --git a/litellm-rust/crates/python-bridge/src/routes/audio_transcription.rs b/litellm-rust/crates/python-bridge/src/routes/audio_transcription/value.rs similarity index 100% rename from litellm-rust/crates/python-bridge/src/routes/audio_transcription.rs rename to litellm-rust/crates/python-bridge/src/routes/audio_transcription/value.rs 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 new file mode 100644 index 00000000000..f2997ee278c --- /dev/null +++ b/litellm-rust/crates/python-bridge/src/routes/chat_completions/mod.rs @@ -0,0 +1,12 @@ +mod value; + +use pyo3::prelude::*; + +pub(super) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + value::register(module) +} + +#[cfg(feature = "trace-parity")] +pub(super) fn register_trace(module: &Bound<'_, PyModule>) -> PyResult<()> { + value::register_trace(module) +} diff --git a/litellm-rust/crates/python-bridge/src/routes/chat_completions.rs b/litellm-rust/crates/python-bridge/src/routes/chat_completions/value.rs similarity index 95% rename from litellm-rust/crates/python-bridge/src/routes/chat_completions.rs rename to litellm-rust/crates/python-bridge/src/routes/chat_completions/value.rs index 08ab476005c..e67bfa89cc7 100644 --- a/litellm-rust/crates/python-bridge/src/routes/chat_completions.rs +++ b/litellm-rust/crates/python-bridge/src/routes/chat_completions/value.rs @@ -9,12 +9,12 @@ use pyo3::prelude::*; use serde_json::Value; use crate::errors::chat_completions_error_to_pyerr; -use crate::marshal::{RouteOptions, RouteOptionsInputs, object_or_empty, required_value}; +use crate::marshal::{RouteOptions, RouteOptionsInputs, object_or_empty, required_array}; fn prepare_chat_completions( inputs: ChatCompletionsInputs, ) -> PyResult> + Send + 'static> { - let messages = required_value("messages", inputs.messages, Value::is_array, "list")?; + 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, @@ -36,7 +36,7 @@ fn prepare_chat_completions( } = options; run_chat_completions(ChatCompletionsRequest { model: &model, - messages, + messages: Value::Array(messages), optional_params, api_key: api_key.as_deref(), api_base: api_base.as_deref(), diff --git a/litellm-rust/crates/python-bridge/src/routes/definition.rs b/litellm-rust/crates/python-bridge/src/routes/definition.rs index 97313651011..571042062f5 100644 --- a/litellm-rust/crates/python-bridge/src/routes/definition.rs +++ b/litellm-rust/crates/python-bridge/src/routes/definition.rs @@ -389,6 +389,82 @@ mod tests { }); } + #[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(); diff --git a/litellm-rust/crates/python-bridge/src/routes/gateway_messages.rs b/litellm-rust/crates/python-bridge/src/routes/gateway_messages.rs deleted file mode 100644 index 97ff93f299a..00000000000 --- a/litellm-rust/crates/python-bridge/src/routes/gateway_messages.rs +++ /dev/null @@ -1,29 +0,0 @@ -use pyo3::prelude::*; -use serde_json::Value; - -use crate::errors::core_error_to_pyerr; - -#[pyfunction] -fn gateway_messages<'py>( - py: Python<'py>, - model_alias: String, - provider_model: String, - api_base: String, - #[pyo3(from_py_with = litellm_python_interop::from_py)] body: Value, -) -> PyResult> { - let future = litellm_ai_gateway::trace_parity::messages_request( - model_alias, - provider_model, - api_base, - body, - ); - crate::execution::run_async( - py, - crate::function_trace::capture(future), - core_error_to_pyerr, - ) -} - -pub(super) fn register_trace(module: &Bound<'_, PyModule>) -> PyResult<()> { - super::definition::add_function(module, wrap_pyfunction!(gateway_messages, module)?) -} diff --git a/litellm-rust/crates/python-bridge/src/routes/messages/mod.rs b/litellm-rust/crates/python-bridge/src/routes/messages/mod.rs new file mode 100644 index 00000000000..f2997ee278c --- /dev/null +++ b/litellm-rust/crates/python-bridge/src/routes/messages/mod.rs @@ -0,0 +1,12 @@ +mod value; + +use pyo3::prelude::*; + +pub(super) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + value::register(module) +} + +#[cfg(feature = "trace-parity")] +pub(super) fn register_trace(module: &Bound<'_, PyModule>) -> PyResult<()> { + value::register_trace(module) +} diff --git a/litellm-rust/crates/python-bridge/src/routes/messages.rs b/litellm-rust/crates/python-bridge/src/routes/messages/value.rs similarity index 94% rename from litellm-rust/crates/python-bridge/src/routes/messages.rs rename to litellm-rust/crates/python-bridge/src/routes/messages/value.rs index f69b5e9251d..b741e54f0ca 100644 --- a/litellm-rust/crates/python-bridge/src/routes/messages.rs +++ b/litellm-rust/crates/python-bridge/src/routes/messages/value.rs @@ -6,12 +6,12 @@ use serde_json::Value; use std::future::Future; use crate::errors::core_error_to_pyerr; -use crate::marshal::{RouteOptions, RouteOptionsInputs, required_value}; +use crate::marshal::{RouteOptions, RouteOptionsInputs, required_object}; fn prepare_messages( inputs: MessagesInputs, ) -> PyResult> + Send + 'static> { - let body = required_value("body", inputs.body, Value::is_object, "dict")?; + let body = required_object("body", inputs.body)?; let options = RouteOptions::from_python(RouteOptionsInputs { model: inputs.model, api_key: inputs.api_key, @@ -32,7 +32,7 @@ fn prepare_messages( } = options; run_messages(MessagesRequest { model: &model, - body, + body: Value::Object(body), api_key: api_key.as_deref(), api_base: api_base.as_deref(), custom_llm_provider: custom_llm_provider.as_deref(), diff --git a/litellm-rust/crates/python-bridge/src/routes/mod.rs b/litellm-rust/crates/python-bridge/src/routes/mod.rs index 7e81f2ffe9b..97c39a5d6b3 100644 --- a/litellm-rust/crates/python-bridge/src/routes/mod.rs +++ b/litellm-rust/crates/python-bridge/src/routes/mod.rs @@ -3,9 +3,6 @@ use pyo3::prelude::*; #[macro_use] mod definition; -#[cfg(feature = "trace-parity")] -mod gateway_messages; - mod audio_transcription; mod chat_completions; mod messages; @@ -16,6 +13,7 @@ pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { audio_transcription::register(module)?; messages::register(module)?; chat_completions::register(module)?; + #[cfg(feature = "trace-parity")] { let trace = PyModule::new(module.py(), "_trace")?; @@ -23,7 +21,6 @@ pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { audio_transcription::register_trace(&trace)?; messages::register_trace(&trace)?; chat_completions::register_trace(&trace)?; - gateway_messages::register_trace(&trace)?; module.add_submodule(&trace)?; } Ok(()) diff --git a/litellm-rust/crates/python-bridge/src/routes/ocr/callbacks.rs b/litellm-rust/crates/python-bridge/src/routes/ocr/callbacks.rs new file mode 100644 index 00000000000..1cbe8a179e3 --- /dev/null +++ b/litellm-rust/crates/python-bridge/src/routes/ocr/callbacks.rs @@ -0,0 +1,161 @@ +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)?; + } + } + 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 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")? + .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_lifecycle")? + .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 new file mode 100644 index 00000000000..d43c2f88775 --- /dev/null +++ b/litellm-rust/crates/python-bridge/src/routes/ocr/document.rs @@ -0,0 +1,264 @@ +use std::io::Read; +use std::path::PathBuf; + +use pyo3::exceptions::{PyFileNotFoundError, PyTypeError, PyValueError}; +use pyo3::prelude::*; +use pyo3::pybacked::PyBackedBytes; +#[cfg(test)] +use pyo3::types::PyDict; +use pyo3::types::{PyBytes, PyString}; + +use litellm_core::constants::OCR_INLINE_MAX_BYTES; +use litellm_core::ocr::{OcrDocument, encode_file_document, mime_type_for_name, upload_mime_type}; +use litellm_python_interop::to_py_preserving_errors; + +enum FileBytes { + Python(PyBackedBytes), + Native(Vec), +} + +impl AsRef<[u8]> for FileBytes { + fn as_ref(&self) -> &[u8] { + match self { + Self::Python(bytes) => bytes, + Self::Native(bytes) => bytes, + } + } +} + +fn read_file_input( + py: Python<'_>, + file: &Bound<'_, PyAny>, +) -> PyResult<(FileBytes, Option)> { + if file.is_instance_of::() { + return Err(PyValueError::new_err( + "OCR file input does not accept bare str values. Pass bytes, a pathlib.Path, or a file-like object.", + )); + } + if file.is_instance(&py.import("os")?.getattr("PathLike")?)? { + let path: PathBuf = file.extract()?; + let name = path + .file_name() + .map(|value| value.to_string_lossy().into_owned()); + let bytes = py + .detach(|| { + let mut bytes = Vec::new(); + std::fs::File::open(&path)? + .take(OCR_INLINE_MAX_BYTES as u64 + 1) + .read_to_end(&mut bytes)?; + Ok::<_, std::io::Error>(bytes) + }) + .map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + PyFileNotFoundError::new_err(format!("File not found: {}", path.display())) + } else { + error.into() + } + })?; + return Ok((FileBytes::Native(bytes), name)); + } + if file.is_instance_of::() { + return Ok((FileBytes::Python(file.extract()?), None)); + } + let reader = file + .getattr_opt("read")? + .filter(|value| value.is_callable()); + let Some(reader) = reader else { + return Err(PyValueError::new_err(format!( + "Unsupported file input type: {}. Expected pathlib.Path, bytes, or a file-like object.", + file.get_type(), + ))); + }; + let name = file + .getattr_opt("name")? + .filter(|value| !value.is_none()) + .map(|value| value.extract::()) + .transpose()?; + let value = reader.call0()?; + let bytes = if value.is_instance_of::() { + FileBytes::Native(value.extract::()?.into_bytes()) + } else if value.is_instance_of::() { + FileBytes::Python(value.extract()?) + } else { + return Err(PyTypeError::new_err(format!( + "OCR file read must return bytes or str, got {}", + value.get_type(), + ))); + }; + Ok((bytes, name)) +} + +pub(super) struct FileDocumentInput { + bytes: FileBytes, + name: Option, + mime_type: Option, +} + +impl FromPyObject<'_, '_> for FileDocumentInput { + type Error = PyErr; + + fn extract(document: Borrowed<'_, '_, PyAny>) -> PyResult { + let py = document.py(); + let mime_type = match document.get_item("mime_type") { + Ok(value) => Some(value.extract::()?), + Err(error) if error.is_instance_of::(py) => None, + Err(error) => return Err(error), + }; + let file = document.get_item("file").map_err(|error| { + if error.is_instance_of::(py) { + PyValueError::new_err("document with type='file' must include a 'file' field containing a pathlib.Path, file-like object, or bytes") + } else { + error + } + })?; + if file.is_none() { + return Err(PyValueError::new_err( + "document with type='file' must include a 'file' field containing a pathlib.Path, file-like object, or bytes", + )); + } + let (bytes, name) = read_file_input(py, &file)?; + Ok(Self { + bytes, + name, + mime_type, + }) + } +} + +pub(super) fn file_document(py: Python<'_>, document: FileDocumentInput) -> PyResult { + py.detach(|| { + encode_file_document( + document.bytes.as_ref(), + document.name.as_deref(), + document.mime_type.as_deref(), + ) + }) + .map_err(|error| PyValueError::new_err(error.to_string())) +} + +#[pyfunction] +fn _ocr_file_document(py: Python<'_>, document: Bound<'_, PyAny>) -> PyResult> { + to_py_preserving_errors(py, &file_document(py, document.extract()?)?) +} + +#[pyfunction] +fn _ocr_mime_type(file_name: &str) -> String { + mime_type_for_name(file_name).into() +} + +#[pyfunction] +#[pyo3(signature = (file_content, file_name=None, content_type=None))] +fn _ocr_upload_document( + py: Python<'_>, + file_content: &Bound<'_, PyBytes>, + file_name: Option<&str>, + content_type: Option<&str>, +) -> PyResult> { + let bytes: PyBackedBytes = file_content.extract()?; + let document = py + .detach(|| { + encode_file_document( + &bytes, + None, + Some(upload_mime_type(file_name, content_type)), + ) + }) + .map_err(|error| PyValueError::new_err(error.to_string()))?; + to_py_preserving_errors(py, &document) +} + +pub(super) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add("_OCR_MAX_FILE_BYTES", OCR_INLINE_MAX_BYTES)?; + module.add_function(wrap_pyfunction!(_ocr_upload_document, module)?)?; + module.add_function(wrap_pyfunction!(_ocr_file_document, module)?)?; + module.add_function(wrap_pyfunction!(_ocr_mime_type, module)?) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extraction_validates_required_file_and_optional_mime_type() { + Python::initialize(); + Python::attach(|py| { + for expression in [c"{}", c"{'file': None}"] { + let document = py.eval(expression, None, None).unwrap(); + let error = document.extract::().err().unwrap(); + assert!(error.is_instance_of::(py)); + assert!(error.to_string().contains("must include a 'file' field")); + } + for expression in [ + c"{'file': b'abc', 'mime_type': None}", + c"{'file': b'abc', 'mime_type': 7}", + ] { + let document = py.eval(expression, None, None).unwrap(); + let error = document.extract::().err().unwrap(); + assert!(error.is_instance_of::(py)); + } + let document = py.eval(c"{'file': b'abc'}", None, None).unwrap(); + let input: FileDocumentInput = document.extract().unwrap(); + assert_eq!(input.bytes.as_ref(), b"abc"); + assert_eq!(input.name, None); + assert_eq!(input.mime_type, None); + }); + } + + #[test] + fn extraction_validates_mime_type_before_consuming_file() { + Python::initialize(); + Python::attach(|py| { + let locals = PyDict::new(py); + py.run( + c"class Reader: + def __init__(self): + self.reads = 0 + def read(self): + self.reads += 1 + return b'abc' +reader = Reader() +document = {'file': reader, 'mime_type': 7}", + Some(&locals), + Some(&locals), + ) + .unwrap(); + let document = locals.get_item("document").unwrap().unwrap(); + let error = document.extract::().err().unwrap(); + assert!(error.is_instance_of::(py)); + let reads: usize = locals + .get_item("reader") + .unwrap() + .unwrap() + .getattr("reads") + .unwrap() + .extract() + .unwrap(); + assert_eq!(reads, 0); + }); + } + + #[test] + fn extraction_preserves_reader_key_error_identity() { + Python::initialize(); + Python::attach(|py| { + let locals = PyDict::new(py); + py.run( + c"failure = KeyError('reader failed') +class Reader: + def read(self): + raise failure +document = {'file': Reader()}", + Some(&locals), + Some(&locals), + ) + .unwrap(); + let document = locals.get_item("document").unwrap().unwrap(); + let error = document.extract::().err().unwrap(); + assert!( + error + .value(py) + .is(locals.get_item("failure").unwrap().unwrap()) + ); + }); + } +} diff --git a/litellm-rust/crates/python-bridge/src/routes/ocr/errors.rs b/litellm-rust/crates/python-bridge/src/routes/ocr/errors.rs new file mode 100644 index 00000000000..66bdfb7583e --- /dev/null +++ b/litellm-rust/crates/python-bridge/src/routes/ocr/errors.rs @@ -0,0 +1,72 @@ +use litellm_core::error::Error; +use pyo3::prelude::*; + +use crate::errors::{RustUpstreamError, core_error_to_pyerr}; + +pub(super) fn to_pyerr(error: Error) -> PyErr { + let status = error.http_status_code(); + let mapped = match error { + Error::Http { status, body } => RustUpstreamError::new_err((status, body)), + other => core_error_to_pyerr(other), + }; + attach_status(mapped, status) +} + +fn attach_status(error: PyErr, status: Option) -> PyErr { + if let Some(status) = status { + Python::attach(|py| { + let value = error.value(py); + value.setattr("status_code", status).ok(); + value.setattr("message", value.to_string()).ok(); + }); + } + error +} + +#[cfg(test)] +mod tests { + use super::*; + use pyo3::exceptions::PyValueError; + + #[test] + fn preserves_python_validation_and_provider_details() { + Python::initialize(); + Python::attach(|py| { + let mapped = to_pyerr(Error::MissingDocumentUrl); + assert!(mapped.is_instance_of::(py)); + assert_eq!(mapped.value(py).to_string(), "Document URL is required"); + assert_eq!( + mapped + .value(py) + .getattr("status_code") + .unwrap() + .extract::() + .unwrap(), + 500 + ); + let mapped = to_pyerr(Error::Http { + status: 429, + body: r#"{"message":"rate limited"}"#.to_string(), + }); + assert!(mapped.is_instance_of::(py)); + let args: (u16, String) = mapped + .value(py) + .getattr("args") + .and_then(|args| args.extract()) + .expect("OCR failures retain status and unprefixed provider message"); + assert_eq!(args, (429, r#"{"message":"rate limited"}"#.to_string())); + + let mapped = to_pyerr(Error::InvalidRequest("invalid format".into())); + assert!(mapped.is_instance_of::(py)); + assert_eq!( + mapped + .value(py) + .getattr("status_code") + .unwrap() + .extract::() + .unwrap(), + 400 + ); + }); + } +} diff --git a/litellm-rust/crates/python-bridge/src/routes/ocr/lifecycle.rs b/litellm-rust/crates/python-bridge/src/routes/ocr/lifecycle.rs new file mode 100644 index 00000000000..12d902a3544 --- /dev/null +++ b/litellm-rust/crates/python-bridge/src/routes/ocr/lifecycle.rs @@ -0,0 +1,311 @@ +use pyo3::prelude::*; +use pyo3::types::{PyDict, PyTuple}; + +use litellm_core::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)?; + } + } + retained_fields.set_item("document", &self.projected()?.fields.document)?; + let projected = self.projected_mut()?; + projected.retained_fields = Some(retained_fields.unbind()); + projected.pre_call = Some((&request).into()); + Ok(request) + } + + 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::Error) -> PyErr { + ocr_error_to_pyerr(error) + } + + 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(py, 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::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)?; + 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 + } +} + +#[pyfunction] +fn _ocr_lifecycle( + 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) +} + +pub(super) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add_function(wrap_pyfunction!(_ocr_lifecycle, 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 new file mode 100644 index 00000000000..10fa40b65ea --- /dev/null +++ b/litellm-rust/crates/python-bridge/src/routes/ocr/mod.rs @@ -0,0 +1,19 @@ +mod callbacks; +mod document; +mod errors; +mod lifecycle; +mod project; +mod value; + +use pyo3::prelude::*; + +pub(super) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + value::register(module)?; + document::register(module)?; + lifecycle::register(module) +} + +#[cfg(feature = "trace-parity")] +pub(super) fn register_trace(module: &Bound<'_, PyModule>) -> PyResult<()> { + value::register_trace(module) +} diff --git a/litellm-rust/crates/python-bridge/src/routes/ocr/project.rs b/litellm-rust/crates/python-bridge/src/routes/ocr/project.rs new file mode 100644 index 00000000000..8b6a1b02e19 --- /dev/null +++ b/litellm-rust/crates/python-bridge/src/routes/ocr/project.rs @@ -0,0 +1,579 @@ +use std::sync::Arc; + +use litellm_core::ocr::wire::{OcrWireRequest, consumed_optional_params, decode_request}; +use litellm_core::ocr::{LiteLLMOcrRequest, NativeOutcome, OcrCall}; +use litellm_python_interop::{ + from_py_preserving_errors as from_py, to_py_preserving_errors as to_py, +}; +use pyo3::prelude::*; +use pyo3::types::PyDict; +use serde_json::{Map, Value}; + +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::marshal::{project_optional_fields, python_timeout_seconds, request_input_sources}; + +pub(super) struct ProjectedOcrFields { + pub boundary_request: Py, + pub document: Py, + pub api_key: Py, + 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> { + request: &'a Bound<'py, PyAny>, + kwargs: &'a Bound<'py, PyDict>, +} + +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), + } + } + + fn model(&self) -> PyResult { + self.lookup("model")?.extract() + } + + fn custom_llm_provider(&self) -> PyResult> { + self.lookup("custom_llm_provider")?.extract() + } + + fn document(&self) -> PyResult> { + self.lookup("document") + } + + fn api_key(&self) -> PyResult> { + self.lookup("api_key") + } + + fn api_base(&self) -> PyResult> { + self.lookup("api_base")?.extract() + } + + fn extra_headers(&self) -> PyResult>> { + self.lookup("extra_headers")? + .extract::>>()? + .map(|value| from_py(value.bind(self.request.py()))) + .transpose() + } + + fn timeout_seconds(&self) -> PyResult> { + Ok(self + .lookup("timeout")? + .extract::>>()? + .map(|value| python_timeout_seconds(self.request.py(), value)) + .transpose()? + .flatten()) + } +} + +enum ProjectedDocument { + File { wire: Value, retained: Py }, + Other { wire: Value, retained: Py }, +} + +impl ProjectedDocument { + fn project(py: Python<'_>, document: &Bound<'_, PyAny>) -> PyResult { + let kind: String = document.get_item("type")?.extract()?; + if kind != "file" { + return Ok(Self::Other { + wire: from_py(document)?, + retained: document.clone().unbind(), + }); + } + let input = document.extract()?; + let encoded = super::document::file_document(py, input)?; + let wire = serde_json::to_value(encoded) + .map_err(|error| pyo3::exceptions::PyValueError::new_err(error.to_string()))?; + Ok(Self::File { + retained: to_py(py, &wire)?, + wire, + }) + } + + fn into_parts(self) -> (Value, Py) { + match self { + Self::File { wire, retained } | Self::Other { wire, retained } => (wire, retained), + } + } +} + +pub(super) fn project_request( + py: Python<'_>, + request: &Bound<'_, PyAny>, + kwargs: &Bound<'_, PyDict>, +) -> PyResult { + let boundary_request = request.clone().unbind(); + let arguments = OcrArguments { request, kwargs }; + let model = arguments.model()?; + let custom_llm_provider = arguments.custom_llm_provider()?; + let (wire_document, retained_document) = + ProjectedDocument::project(py, &arguments.document()?)?.into_parts(); + let api_key = arguments.api_key()?; + let specs = consumed_optional_params(&model, custom_llm_provider.as_deref()) + .map_err(ocr_error_to_pyerr)?; + let names = specs.iter().map(|spec| spec.name).collect::>(); + let optional_params = project_optional_fields(kwargs, &names)?; + let input_sources = request_input_sources( + kwargs, + names + .iter() + .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 wire = OcrWireRequest { + model, + document: wire_document, + api_key: api_key.extract()?, + api_base: arguments.api_base()?, + custom_llm_provider, + extra_headers: arguments.extra_headers()?, + optional_params, + input_sources, + timeout_seconds: arguments.timeout_seconds()?, + }; + let request = decode_request(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, + 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::Error; + use litellm_core::ocr::OcrDecline; + use pyo3::exceptions::{PyKeyError, PyTypeError, PyValueError}; + + use super::*; + + fn eval<'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 + } + + fn arguments<'a, 'py>( + request: &'a Bound<'py, PyAny>, + kwargs: &'a Bound<'py, PyDict>, + ) -> OcrArguments<'a, 'py> { + OcrArguments { request, kwargs } + } + + fn project_document( + py: Python<'_>, + document: &Bound<'_, PyAny>, + ) -> PyResult<(Value, Py)> { + ProjectedDocument::project(py, document).map(ProjectedDocument::into_parts) + } + + fn stub_timeout_conversion(py: Python<'_>) { + eval( + py, + c" +import sys +import types +timeouts = types.ModuleType('litellm.rust_bridge.timeouts') +timeouts.timeout_to_seconds = lambda timeout: None if timeout is None else float(timeout) +sys.modules.setdefault('litellm', types.ModuleType('litellm')) +sys.modules.setdefault('litellm.rust_bridge', types.ModuleType('litellm.rust_bridge')) +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(); + Python::attach(|py| { + let locals = eval( + py, + c" +class Request: + def __init__(self): + self.accesses = [] + def __getattribute__(self, name): + if name != 'accesses': + object.__getattribute__(self, 'accesses').append(name) + return object.__getattribute__(self, name) +request = Request() +request.model = 'from-request' +request.custom_llm_provider = 'mistral' +kwargs = {'model': 'from-kwargs', 'custom_llm_provider': None} +", + ); + let request = locals.get_item("request").unwrap().unwrap(); + let kwargs = locals + .get_item("kwargs") + .unwrap() + .unwrap() + .cast_into::() + .unwrap(); + let arguments = arguments(&request, &kwargs); + assert_eq!(arguments.model().unwrap(), "from-kwargs"); + assert_eq!(arguments.custom_llm_provider().unwrap(), None); + let accesses: Vec = request.getattr("accesses").unwrap().extract().unwrap(); + assert_eq!(accesses, Vec::::new()); + }); + } + + #[test] + fn missing_kwargs_read_the_request_property_once() { + Python::initialize(); + Python::attach(|py| { + let locals = eval( + py, + c" +class Request: + def __init__(self): + self.reads = 0 + @property + def model(self): + self.reads += 1 + return 'mistral-ocr-latest' +request = Request() +kwargs = {} +", + ); + let request = locals.get_item("request").unwrap().unwrap(); + let kwargs = locals + .get_item("kwargs") + .unwrap() + .unwrap() + .cast_into::() + .unwrap(); + assert_eq!( + arguments(&request, &kwargs).model().unwrap(), + "mistral-ocr-latest" + ); + assert_eq!( + request.getattr("reads").unwrap().extract::().unwrap(), + 1 + ); + }); + } + + #[test] + fn request_property_exceptions_keep_their_identity() { + Python::initialize(); + Python::attach(|py| { + let locals = eval( + py, + c" +failure = LookupError('model failed') +class Request: + @property + def model(self): + raise failure +request = Request() +kwargs = {} +", + ); + let request = locals.get_item("request").unwrap().unwrap(); + let kwargs = locals + .get_item("kwargs") + .unwrap() + .unwrap() + .cast_into::() + .unwrap(); + let error = arguments(&request, &kwargs).model().unwrap_err(); + assert!( + error + .value(py) + .is(locals.get_item("failure").unwrap().unwrap()) + ); + }); + } + + #[test] + fn unused_raising_property_is_never_inspected() { + Python::initialize(); + Python::attach(|py| { + let locals = eval( + py, + c" +class Request: + @property + def unused(self): + raise RuntimeError('unused') + model = 'mistral-ocr-latest' + custom_llm_provider = None +request = Request() +kwargs = {} +", + ); + let request = locals.get_item("request").unwrap().unwrap(); + let kwargs = locals + .get_item("kwargs") + .unwrap() + .unwrap() + .cast_into::() + .unwrap(); + let arguments = arguments(&request, &kwargs); + assert_eq!(arguments.model().unwrap(), "mistral-ocr-latest"); + assert_eq!(arguments.custom_llm_provider().unwrap(), None); + }); + } + + #[test] + fn document_reader_mutations_are_visible_to_later_field_reads() { + Python::initialize(); + Python::attach(|py| { + stub_timeout_conversion(py); + let locals = eval( + py, + c" +class Request: + api_base = 'original' + timeout = 1 + @property + def document(self): + return document +class Reader: + def read(self): + Request.api_base = 'mutated' + Request.timeout = 9 + return b'abc' +document = {'type': 'file', 'file': Reader()} +request = Request() +kwargs = {} +", + ); + let request = locals.get_item("request").unwrap().unwrap(); + let kwargs = locals + .get_item("kwargs") + .unwrap() + .unwrap() + .cast_into::() + .unwrap(); + let arguments = arguments(&request, &kwargs); + let document = arguments.document().unwrap(); + project_document(py, &document).unwrap(); + assert_eq!(arguments.api_base().unwrap().as_deref(), Some("mutated")); + assert_eq!(arguments.timeout_seconds().unwrap(), Some(9.0)); + }); + } + + #[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_are_encoded_and_other_documents_keep_the_python_object() { + Python::initialize(); + Python::attach(|py| { + let file = py + .eval( + c"{'type': 'file', 'file': b'%PDF-1.4', 'mime_type': 'application/pdf'}", + None, + None, + ) + .unwrap(); + assert_eq!( + project_document(py, &file).unwrap().0, + serde_json::json!({ + "type": "document_url", + "document_url": "data:application/pdf;base64,JVBERi0xLjQ=", + }) + ); + + let original = py + .eval( + c"{'type': 'document_url', 'document_url': 'https://example.com/a.pdf'}", + None, + None, + ) + .unwrap(); + let (wire, retained) = project_document(py, &original).unwrap(); + assert_eq!( + wire, + serde_json::json!({ + "type": "document_url", + "document_url": "https://example.com/a.pdf", + }) + ); + assert!(retained.bind(py).is(&original)); + }); + } + + #[test] + fn unknown_document_types_reach_existing_downstream_validation() { + Python::initialize(); + Python::attach(|py| { + let document = py + .eval(c"{'type': 'mystery', 'mystery': 'x'}", None, None) + .unwrap(); + let wire_document = project_document(py, &document).unwrap().0; + assert_eq!( + wire_document, + serde_json::json!({"type": "mystery", "mystery": "x"}) + ); + let error = match decode_request(OcrWireRequest { + model: "mistral/mistral-ocr-latest".into(), + document: wire_document, + api_key: None, + api_base: None, + custom_llm_provider: None, + extra_headers: None, + optional_params: Map::new(), + input_sources: Default::default(), + timeout_seconds: None, + }) { + Ok(_) => panic!("unknown discriminators belong to core validation"), + Err(error) => error, + }; + assert!(error.to_string().contains("document")); + }); + } + + #[test] + fn document_discriminator_errors_keep_their_existing_exceptions() { + Python::initialize(); + Python::attach(|py| { + let missing = py.eval(c"{}", None, None).unwrap(); + assert!( + project_document(py, &missing) + .unwrap_err() + .is_instance_of::(py) + ); + + let non_string = py.eval(c"{'type': 1}", None, None).unwrap(); + assert!( + project_document(py, &non_string) + .unwrap_err() + .is_instance_of::(py) + ); + + let locals = eval( + py, + c" +failure = RuntimeError('type lookup failed') +class Document: + def __getitem__(self, key): + raise failure +document = Document() +", + ); + let error = + project_document(py, &locals.get_item("document").unwrap().unwrap()).unwrap_err(); + assert!( + error + .value(py) + .is(locals.get_item("failure").unwrap().unwrap()) + ); + }); + } + + #[test] + fn document_classification_happens_once() { + Python::initialize(); + Python::attach(|py| { + let locals = eval( + py, + c" +class Document(dict): + def __init__(self): + super().__init__({'file': b'abc'}) + self.reads = [] + def __getitem__(self, key): + self.reads.append(key) + if key == 'type': + return 'file' if self.reads.count('type') == 1 else 'document_url' + return super().__getitem__(key) +document = Document() +", + ); + let document = locals.get_item("document").unwrap().unwrap(); + let (wire, retained) = project_document(py, &document).unwrap(); + assert_eq!(wire["type"], "document_url"); + assert!(!retained.bind(py).is(&document)); + 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/ocr.rs b/litellm-rust/crates/python-bridge/src/routes/ocr/value.rs similarity index 52% rename from litellm-rust/crates/python-bridge/src/routes/ocr.rs rename to litellm-rust/crates/python-bridge/src/routes/ocr/value.rs index c5def64c2f1..051ac19d4fb 100644 --- a/litellm-rust/crates/python-bridge/src/routes/ocr.rs +++ b/litellm-rust/crates/python-bridge/src/routes/ocr/value.rs @@ -1,12 +1,11 @@ use litellm_core::Error; use std::future::Future; -use litellm_ai_gateway::io::ocr::{OcrRequest, ocr as run_ocr}; -use litellm_core::ocr::wire::{OcrWireRequest, decode_request, is_supported_request}; +use litellm_core::ocr::wire::{OcrWireRequest, decode_request}; use pyo3::prelude::*; use serde_json::Value; -use crate::errors::ocr_error_to_pyerr; +use super::errors::to_pyerr as ocr_error_to_pyerr; use crate::marshal::{RouteOptions, RouteOptionsInputs, object_or_empty}; fn prepare_ocr( @@ -38,37 +37,20 @@ fn prepare_ocr( extra_headers, timeout, } = options; - if is_supported_request(&model, custom_llm_provider.as_deref()) { - let request = decode_request(OcrWireRequest { - model, - document, - api_key, - api_base, - custom_llm_provider, - extra_headers, - optional_params, - input_sources, - timeout_seconds: timeout.map(|value| value.as_secs_f64()), - })?; - return litellm_core::ocr::ocr(request) - .await - .map(|response| response.into_json()); - } - run_ocr(OcrRequest { - model: &model, + let request = decode_request(OcrWireRequest { + model, document, - api_key: api_key.as_deref(), - api_base: api_base.as_deref(), - custom_llm_provider: custom_llm_provider.as_deref(), + api_key, + api_base, + custom_llm_provider, extra_headers, optional_params, - timeout, - callbacks: Vec::new(), - guardrails: Vec::new(), - request_metadata: Default::default(), - litellm_call_id: None, - }) - .await + input_sources, + timeout_seconds: timeout.map(|value| value.as_secs_f64()), + })?; + litellm_core::ocr::ocr(request) + .await + .map(|response| response.into_json()) }) } @@ -96,22 +78,3 @@ bridge_route! { prepare = prepare_ocr, errors = ocr_error_to_pyerr, } - -#[cfg(test)] -mod tests { - use litellm_core::ocr::wire::is_supported_request; - - #[test] - fn native_activation_includes_migrated_providers() { - assert!(is_supported_request("model", Some("mistral"))); - assert!(is_supported_request("pixtral-12b", Some("azure_ai"))); - assert!(is_supported_request( - "documentintelligence/prebuilt-read", - Some("azure_ai") - )); - assert!(is_supported_request("parse-v3", Some("reducto"))); - assert!(is_supported_request("parse-legacy", Some("reducto"))); - assert!(is_supported_request("mistral-ocr", Some("vertex_ai"))); - assert!(is_supported_request("deepseek-ocr", Some("vertex_ai"))); - } -} diff --git a/litellm-rust/crates/python-bridge/tests/lifecycle.py b/litellm-rust/crates/python-bridge/tests/lifecycle.py new file mode 100644 index 00000000000..fd6742102a4 --- /dev/null +++ b/litellm-rust/crates/python-bridge/tests/lifecycle.py @@ -0,0 +1,186 @@ +import asyncio +import gc +import threading +import weakref +from contextvars import ContextVar + + +async def exercise(): + caller = asyncio.current_task() + thread = threading.get_ident() + loop = asyncio.get_running_loop() + marker = ContextVar("driver", default="before") + entered = asyncio.Event() + released = asyncio.Event() + result = object() + + class CustomAwaitable: + def __await__(self): + return operation().__await__() + + async def operation(): + assert asyncio.current_task() is caller + assert threading.get_ident() == thread + assert asyncio.get_running_loop() is loop + marker.set("inside") + entered.set() + await released.wait() + assert asyncio.current_task() is caller + assert marker.get() == "inside" + return result + + async def release(): + await entered.wait() + released.set() + + releaser = asyncio.create_task(release()) + execution = await_execution(CustomAwaitable()) + try: + execution.resume_value(None) + except RuntimeError: + pass + else: + raise AssertionError("resumed an unstarted execution") + wrapped = drive(execution) + try: + wrapped.send(1) + except TypeError: + pass + else: + raise AssertionError("accepted initial value") + assert await wrapped is result + assert marker.get() == "inside" + await releaser + execution.close() + execution.close() + try: + await wrapped + except RuntimeError: + pass + else: + raise AssertionError("accepted coroutine reuse") + + final_awaitable = CustomAwaitable() + assert await drive(calling_execution(lambda: final_awaitable)) is final_awaitable + + cause = KeyError("cause") + failure = ValueError("original") + + async def failing(): + await asyncio.sleep(0) + raise failure from cause + + try: + await drive(await_execution(failing())) + except ValueError as error: + assert error is failure + assert error.__cause__ is cause + names = [] + traceback = error.__traceback__ + while traceback: + names.append(traceback.tb_frame.f_code.co_name) + traceback = traceback.tb_next + assert "failing" in names + else: + raise AssertionError("lost original exception") + + for suppress in (False, True): + pending = asyncio.Event() + cleanup_entered = asyncio.Event() + cleanup_release = asyncio.Event() + cleaned = [] + + async def cancel_operation(): + try: + pending.set() + await asyncio.Event().wait() + except asyncio.CancelledError: + if suppress: + return result + raise + finally: + cleanup_entered.set() + try: + await cleanup_release.wait() + except asyncio.CancelledError: + await cleanup_release.wait() + cleaned.append(asyncio.current_task()) + + task = asyncio.create_task(drive(await_execution(cancel_operation()))) + await pending.wait() + task.cancel() + await cleanup_entered.wait() + assert not task.done() + task.cancel() + await asyncio.sleep(0) + cleanup_release.set() + if suppress: + assert await task is result + else: + try: + await task + except asyncio.CancelledError: + pass + else: + raise AssertionError("lost cancellation") + assert cleaned == [task] + + observed = [] + + def reenter(): + try: + active.start() + except RuntimeError as error: + observed.append(str(error)) + return result + + active = calling_execution(reenter) + assert await drive(active) is result + assert observed == ["execution is already running"] + + class Finalizer: + def __call__(self): + return result + + def __del__(self): + self.owner.close() + observed.append("released") + + def cycle(started): + callback = Finalizer() + execution = calling_execution(callback) + callback.owner = execution + if started: + assert execution.start().value is result + return weakref.ref(callback) + + for started in (False, True): + reference = cycle(started) + gc.collect() + assert reference() is None + assert observed[-2:] == ["released", "released"] + + class Awaitable: + def __await__(self): + try: + yield self + finally: + observed.append("unwound") + + def abandoned(started): + awaitable = Awaitable() + coroutine = drive(await_execution(awaitable)) + awaitable.owner = coroutine + if started: + assert coroutine.send(None) is awaitable + coroutine.close() + return weakref.ref(awaitable) + + for started in (False, True): + reference = abandoned(started) + gc.collect() + assert reference() is None + assert observed[-1] == "unwound" + + +asyncio.run(asyncio.wait_for(exercise(), 10)) diff --git a/litellm-rust/crates/python-interop/AGENTS.md b/litellm-rust/crates/python-interop/AGENTS.md index d1d61e5dfa0..63996d3a92b 100644 --- a/litellm-rust/crates/python-interop/AGENTS.md +++ b/litellm-rust/crates/python-interop/AGENTS.md @@ -1 +1,16 @@ -litellm-python-interop is the domain-neutral PyO3 foundation. Keep generic Python/Serde conversion and interpreter primitives here. Do not add LiteLLM domain crates, route types, API registration, or cdylib build features. +- 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` +- 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` + - Preserve `PythonizeError`'s standard conversion into `PyErr`; do not stringify original Python exceptions into new `ValueError`s + - Keep serializer-panic containment in `Pythonized`: async output conversion can run in an unjoined blocking task and otherwise strand delivery +- 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` +- 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/python-interop/src/lib.rs b/litellm-rust/crates/python-interop/src/lib.rs index 2e562bdae70..79af79e8c61 100644 --- a/litellm-rust/crates/python-interop/src/lib.rs +++ b/litellm-rust/crates/python-interop/src/lib.rs @@ -2,4 +2,6 @@ mod gil; mod marshal; pub use gil::{release_count, release_gil}; -pub use marshal::{Pythonized, from_py, panic_to_pyerr, to_py}; +pub use marshal::{ + Pythonized, from_py, from_py_preserving_errors, panic_to_pyerr, to_py, to_py_preserving_errors, +}; diff --git a/litellm-rust/crates/python-interop/src/marshal.rs b/litellm-rust/crates/python-interop/src/marshal.rs index a16d1e0ae13..ed4cce862c0 100644 --- a/litellm-rust/crates/python-interop/src/marshal.rs +++ b/litellm-rust/crates/python-interop/src/marshal.rs @@ -14,6 +14,13 @@ where pythonize::depythonize(value).map_err(|error| PyValueError::new_err(error.to_string())) } +pub fn from_py_preserving_errors(value: &Bound<'_, PyAny>) -> PyResult +where + T: DeserializeOwned, +{ + pythonize::depythonize(value).map_err(PyErr::from) +} + pub fn to_py(py: Python<'_>, value: &T) -> PyResult> where T: Serialize + ?Sized, @@ -23,6 +30,15 @@ where .map_err(|error| PyValueError::new_err(error.to_string())) } +pub fn to_py_preserving_errors(py: Python<'_>, value: &T) -> PyResult> +where + T: Serialize + ?Sized, +{ + pythonize::pythonize(py, value) + .map(Bound::unbind) + .map_err(PyErr::from) +} + pub struct Pythonized(pub T); impl<'py, T> IntoPyObject<'py> for Pythonized @@ -89,4 +105,49 @@ mod tests { assert_eq!(error.to_string(), "PanicException: serializer panicked"); }); } + + #[test] + fn depythonize_preserves_python_exception_identity_and_traceback() { + Python::initialize(); + Python::attach(|py| { + let locals = pyo3::types::PyDict::new(py); + py.run( + pyo3::ffi::c_str!( + r#" +failure = LookupError('conversion failed') +cause = ValueError('cause') +class Broken: + def __index__(self): + raise failure from cause +value = Broken() +"# + ), + Some(&locals), + Some(&locals), + ) + .unwrap(); + let value = locals.get_item("value").unwrap().unwrap(); + let legacy_error = from_py::(&value).unwrap_err(); + assert!(legacy_error.is_instance_of::(py)); + assert!( + !legacy_error + .value(py) + .is(locals.get_item("failure").unwrap().unwrap()) + ); + let error = from_py_preserving_errors::(&value).unwrap_err(); + assert!( + error + .value(py) + .is(locals.get_item("failure").unwrap().unwrap()) + ); + assert!( + error + .cause(py) + .unwrap() + .value(py) + .is(locals.get_item("cause").unwrap().unwrap()) + ); + assert!(error.traceback(py).is_some()); + }); + } } diff --git a/litellm/__init__.py b/litellm/__init__.py index 1dfd146a00e..ccfbf80369f 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -501,6 +501,7 @@ disable_copilot_system_to_assistant: bool = False # If false (default), convert public_mcp_servers: Optional[List[str]] = None public_mcp_hub_strict_whitelist: bool = True public_model_groups: Optional[List[str]] = None +public_skills_index: bool = False public_agent_groups: Optional[List[str]] = None agent_search_embedding_model: Optional[str] = None mcp_tool_search: Optional[Mapping[str, object]] = None diff --git a/litellm/caching/redis_cache.py b/litellm/caching/redis_cache.py index b54c52655fd..94d7e939f1e 100644 --- a/litellm/caching/redis_cache.py +++ b/litellm/caching/redis_cache.py @@ -16,7 +16,7 @@ import inspect import json import logging import time -from collections.abc import Awaitable, Callable, Sequence +from collections.abc import Awaitable, Callable, Iterator, Sequence from contextvars import ContextVar from dataclasses import dataclass from datetime import timedelta @@ -325,19 +325,37 @@ def _is_redis_health_failure(exc: BaseException) -> bool: def _redis_timeout_error_types() -> tuple[type, ...]: """Health failures that are timeouts rather than unambiguous connectivity errors. - ``builtins.TimeoutError`` covers ``asyncio.TimeoutError`` and ``socket.timeout`` - (aliases since py3.11 / py3.10). ``redis.exceptions.TimeoutError`` does not subclass - either, so it is listed explicitly. + ``builtins.TimeoutError`` covers ``socket.timeout`` (an alias since py3.10) and, from + py3.11, ``asyncio.TimeoutError``; on py3.10 ``asyncio.TimeoutError`` is still its own + class, so it is listed explicitly. ``redis.exceptions.TimeoutError`` subclasses neither. """ try: from redis.exceptions import TimeoutError as RedisTimeoutError except ImportError: - return (TimeoutError,) - return (RedisTimeoutError, TimeoutError) + return (TimeoutError, asyncio.TimeoutError) + return (RedisTimeoutError, TimeoutError, asyncio.TimeoutError) + + +_MAX_EXCEPTION_CAUSE_DEPTH: Final = 20 + + +def _explicit_causes(exc: BaseException) -> Iterator[BaseException]: + current = exc # rebind-ok: advances one link per iteration of the bounded walk + for _ in range(_MAX_EXCEPTION_CAUSE_DEPTH): + yield current + if current.__cause__ is None: + return + current = current.__cause__ def _is_redis_timeout_failure(exc: BaseException) -> bool: - return isinstance(exc, _redis_timeout_error_types()) + """True when ``exc`` or any exception it was explicitly raised ``from`` is a timeout. + + redis-py's blocking pool reports a pool wait timeout as ``ConnectionError`` chained from + ``asyncio.TimeoutError``, which is a busy pool rather than an unreachable Redis. + """ + timeout_types: Final = _redis_timeout_error_types() + return any(isinstance(link, timeout_types) for link in _explicit_causes(exc)) class _BreakerMetrics: diff --git a/litellm/integrations/custom_guardrail.py b/litellm/integrations/custom_guardrail.py index 8a976a966a6..39adea30828 100644 --- a/litellm/integrations/custom_guardrail.py +++ b/litellm/integrations/custom_guardrail.py @@ -5,6 +5,7 @@ import os import secrets from collections.abc import Mapping from datetime import datetime +from types import MappingProxyType from typing import TYPE_CHECKING, Any, ClassVar, Final, Literal, Optional, get_args from litellm._logging import verbose_logger @@ -1279,6 +1280,7 @@ class CustomGuardrail(CustomLogger): guardrail_response: Final = self._summarize_guardrail_response( response=response, original_inputs=original_inputs, + event_type=event_type, ) verbose_logger.debug("Guardrail response: %s", response) @@ -1298,6 +1300,7 @@ class CustomGuardrail(CustomLogger): self, response: object, original_inputs: Mapping[str, object] | None, + event_type: GuardrailEventHooks | None, ) -> object: """Reduce a hook's return value to what is safe to log as ``guardrail_response``. @@ -1305,15 +1308,21 @@ class CustomGuardrail(CustomLogger): returns the (possibly modified) request payload. Neither is a provider verdict, and logging them verbatim ships the user's prompt to every logging sink (OTEL spans, Datadog, spend logs), so both collapse to ``"allow"`` / ``"mask"`` by comparing - against ``original_inputs``, a copy taken before the hook ran. A string result is the - hook's own rejection message (the proxy turns it into a 400), not user input, so it is - logged as is. + against ``original_inputs``, a copy taken before the hook ran. A pre_call baseline only + holds the prompt-bearing keys, so the returned request is narrowed to those same keys + before the comparison. A string result is the hook's own rejection message (the proxy + turns it into a 400), not user input, so it is logged as is. """ if response is None: return {} if original_inputs is None or not isinstance(response, Mapping): return response - return "mask" if self._inputs_were_modified(original_inputs, response) else "allow" + compared_response: Final[Mapping[str, object]] = ( + MappingProxyType({key: value for key, value in response.items() if key in _PRE_CALL_CONTENT_KEYS}) + if event_type == GuardrailEventHooks.pre_call + else response + ) + return "mask" if self._inputs_were_modified(original_inputs, compared_response) else "allow" @staticmethod def _is_guardrail_intervention(e: Exception) -> bool: @@ -1355,8 +1364,8 @@ class CustomGuardrail(CustomLogger): raise e def _inputs_were_modified(self, original_inputs: Mapping[str, object], response: Mapping[str, object]) -> bool: - """True when any baseline key's value differs in ``response`` (mask), False otherwise (allow).""" - return any(response.get(key) != value for key, value in original_inputs.items()) + """True when any key of either mapping differs between them (mask), False otherwise (allow).""" + return any(original_inputs.get(key) != response.get(key) for key in original_inputs.keys() | response.keys()) def mask_content_in_string( self, @@ -1476,13 +1485,13 @@ def _original_inputs_for( ) -> dict | None: # mutable-ok: matches _process_response(original_inputs=) signature """Baseline the hook's return value is compared against to decide "allow" vs "mask". - ``apply_guardrail`` masks a fresh ``inputs`` dict, so that dict is the baseline. Pre-call - hooks edit the request in place and return it, so the baseline is a deep copy of the - prompt-bearing keys taken before the hook runs. + Hooks may edit their argument in place and return it, so the baseline is always a deep + copy taken before the hook runs: the whole ``inputs`` dict for ``apply_guardrail``, the + prompt-bearing request keys for pre-call hooks. """ if func_name == "apply_guardrail": inputs: Final = kwargs.get("inputs") - return inputs if isinstance(inputs, dict) else None + return copy.deepcopy(inputs) if isinstance(inputs, dict) else None if event_type != GuardrailEventHooks.pre_call: return None return {key: copy.deepcopy(value) for key, value in request_data.items() if key in _PRE_CALL_CONTENT_KEYS} diff --git a/litellm/integrations/gcs_bucket/gcs_bucket.py b/litellm/integrations/gcs_bucket/gcs_bucket.py index 31ceb338dcd..e338f490496 100644 --- a/litellm/integrations/gcs_bucket/gcs_bucket.py +++ b/litellm/integrations/gcs_bucket/gcs_bucket.py @@ -29,8 +29,6 @@ class GCSBucketLogger(GCSBucketBase, AdditionalLoggingUtils): def __init__(self, bucket_name: str | None = None) -> None: from litellm.proxy.proxy_server import premium_user - super().__init__(bucket_name=bucket_name) - self.batch_size = int(os.getenv("GCS_BATCH_SIZE", GCS_DEFAULT_BATCH_SIZE)) self.flush_interval = int(os.getenv("GCS_FLUSH_INTERVAL", GCS_DEFAULT_FLUSH_INTERVAL_SECONDS)) self.use_batched_logging = ( @@ -38,6 +36,7 @@ class GCSBucketLogger(GCSBucketBase, AdditionalLoggingUtils): ) self.flush_lock = asyncio.Lock() super().__init__( + bucket_name=bucket_name, flush_lock=self.flush_lock, batch_size=self.batch_size, flush_interval=self.flush_interval, diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 498d662a906..2d3a99abe81 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -23,7 +23,6 @@ import litellm from litellm import ( _custom_logger_compatible_callbacks_literal, json_logs, - log_raw_request_response, turn_off_message_logging, ) from litellm._logging import ( @@ -563,6 +562,7 @@ 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 @@ -1236,6 +1236,11 @@ class Logging(LiteLLMLoggingBaseClass): additional_args.get("api_base", "") ) + def record_api_call_start_time(self) -> None: + self.model_call_details["api_call_start_time"] = datetime.datetime.now() + if self.model_call_details.get("first_api_call_start_time") is None: + self.model_call_details["first_api_call_start_time"] = self.model_call_details["api_call_start_time"] + def pre_call(self, input, api_key, model=None, additional_args={}): # Log the exact input to the LLM API try: @@ -1253,7 +1258,7 @@ class Logging(LiteLLMLoggingBaseClass): additional_args=additional_args, ) # log raw request to provider (like LangFuse) -- if opted in. - if self.log_raw_request_response is True or log_raw_request_response is True: + if self.log_raw_request_response is True or litellm.log_raw_request_response is True: _litellm_params: Final = self.model_call_details.get("litellm_params", {}) _metadata: Final = _litellm_params.get("metadata", {}) or {} try: @@ -1300,15 +1305,7 @@ class Logging(LiteLLMLoggingBaseClass): "LiteLLM.LoggingError: [Non-Blocking] Exception occurred while logging %s", e ) - self.model_call_details["api_call_start_time"] = datetime.datetime.now() - # Set-once first provider-handoff instant. api_call_start_time - # is overwritten on every retry, so it can't measure one-time - # preprocessing; pinning the first attempt excludes retry loops - # + backoff. Logging object only — must NOT go into - # litellm_params["metadata"] (caller request metadata, typed - # Dict[str, str], echoed downstream; a datetime breaks it). - if self.model_call_details.get("first_api_call_start_time") is None: - self.model_call_details["first_api_call_start_time"] = self.model_call_details["api_call_start_time"] + self.record_api_call_start_time() # Input Integration Logging -> If you want to log the fact that an attempt to call the model was made callbacks: Final = litellm.input_callback + (self.dynamic_input_callbacks or []) for callback in callbacks: @@ -1442,16 +1439,21 @@ class Logging(LiteLLMLoggingBaseClass): """ return _get_masked_values(headers, ignore_sensitive_values=ignore_sensitive_headers) + def record_post_call( + self, original_response: object, input: object, api_key: object, additional_args: dict[str, object] + ) -> None: + self.model_call_details["input"] = input + self.model_call_details["api_key"] = api_key + self.model_call_details["original_response"] = original_response + self.model_call_details["additional_args"] = additional_args + self.model_call_details["log_event_type"] = "post_api_call" + def post_call(self, original_response, input=None, api_key=None, additional_args={}): # Log the exact result from the LLM API, for streaming - log the type of response received if isinstance(original_response, dict): original_response = json.dumps(original_response, default=str) try: - self.model_call_details["input"] = input - self.model_call_details["api_key"] = api_key - self.model_call_details["original_response"] = original_response - self.model_call_details["additional_args"] = additional_args - self.model_call_details["log_event_type"] = "post_api_call" + self.record_post_call(original_response, input, api_key, additional_args) attr: Literal["warning", "debug"] if self.litellm_request_debug: @@ -2116,6 +2118,7 @@ class Logging(LiteLLMLoggingBaseClass): logging_result, start_time, end_time, + build_logging_payload: bool = True, ): """Resolve hidden params, compute response cost, and emit the standard logging payload.""" hidden_params: Final = getattr(logging_result, "_hidden_params", {}) @@ -2140,6 +2143,9 @@ class Logging(LiteLLMLoggingBaseClass): else: self.model_call_details["response_cost"] = self._response_cost_calculator(result=logging_result) + if not build_logging_payload: + return + self.model_call_details["standard_logging_object"] = self._build_standard_logging_payload( logging_result, start_time, end_time ) @@ -2201,6 +2207,7 @@ class Logging(LiteLLMLoggingBaseClass): end_time=None, cache_hit=None, standard_logging_object: StandardLoggingPayload | None = None, + build_logging_payload: bool = True, ): try: if start_time is None: @@ -2238,6 +2245,7 @@ class Logging(LiteLLMLoggingBaseClass): logging_result=logging_result, start_time=start_time, end_time=end_time, + build_logging_payload=build_logging_payload, ) elif standard_logging_object is not None: self.model_call_details["standard_logging_object"] = standard_logging_object @@ -3261,7 +3269,9 @@ class Logging(LiteLLMLoggingBaseClass): except Exception as e: verbose_logger.debug("Error in _handle_callback_failure: %s", e) - def _failure_handler_helper_fn(self, exception, traceback_exception, start_time=None, end_time=None): + def _failure_handler_helper_fn( + self, exception, traceback_exception, start_time=None, end_time=None, build_logging_payload: bool = True + ): if start_time is None: start_time = self.start_time if end_time is None: @@ -3296,6 +3306,9 @@ class Logging(LiteLLMLoggingBaseClass): metadata: Final = self.model_call_details["litellm_params"].get("metadata", {}) or {} metadata.update(exception.headers) + if not build_logging_payload: + return start_time, end_time + ## STANDARDIZED LOGGING PAYLOAD self.model_call_details["standard_logging_object"] = get_standard_logging_object_payload( diff --git a/litellm/llms/azure_ai/ocr/document_intelligence/transformation.py b/litellm/llms/azure_ai/ocr/document_intelligence/transformation.py index f5126f81006..3a2af8a5aba 100644 --- a/litellm/llms/azure_ai/ocr/document_intelligence/transformation.py +++ b/litellm/llms/azure_ai/ocr/document_intelligence/transformation.py @@ -92,6 +92,18 @@ class AzureDocumentIntelligenceOCRConfig(BaseOCRConfig): def get_api_key_env_var(self) -> str | None: return AZURE_DOCUMENT_INTELLIGENCE_API_KEY_ENV_VAR + def resolve_connection_params( + self, + *, + api_key: str | None, + api_base: str | None, + dynamic_api_key: str | None, + dynamic_api_base: str | None, + ) -> tuple[str | None, str | None]: + explicit_api_key: Final = None if api_key is None else dynamic_api_key or api_key + explicit_api_base: Final = None if api_base is None else dynamic_api_base or api_base + return explicit_api_key, explicit_api_base + def get_supported_ocr_params(self, model: str) -> list: """ Get supported OCR parameters for Azure Document Intelligence. @@ -618,7 +630,11 @@ class AzureDocumentIntelligenceOCRConfig(BaseOCRConfig): except SSRFError as ssrf_err: raise ValueError(f"Azure Document Intelligence: rejected polling URL ({ssrf_err})") - poll_headers = {"Ocp-Apim-Subscription-Key": raw_response.request.headers.get("Ocp-Apim-Subscription-Key", "")} + poll_headers: Final = { + header: raw_response.request.headers[header] + for header in ("Ocp-Apim-Subscription-Key", "Authorization") + if header in raw_response.request.headers + } return operation_url, poll_headers @staticmethod diff --git a/litellm/llms/base_llm/ocr/transformation.py b/litellm/llms/base_llm/ocr/transformation.py index 8111f9a194a..bd67dbf1a2a 100644 --- a/litellm/llms/base_llm/ocr/transformation.py +++ b/litellm/llms/base_llm/ocr/transformation.py @@ -144,9 +144,15 @@ class BaseOCRConfig: """ return None - def supports_rust_bridge(self) -> bool: - """Whether the Rust OCR bridge may serve this config when it is enabled for the provider.""" - return True + def resolve_connection_params( + self, + *, + api_key: str | None, + api_base: str | None, + dynamic_api_key: str | None, + dynamic_api_base: str | None, + ) -> tuple[str | None, str | None]: + return dynamic_api_key or api_key, dynamic_api_base or api_base def get_health_check_document(self) -> DocumentType: return { # mutable-ok: litellm.aocr rejects any document that is not a dict diff --git a/litellm/llms/cohere/ocr/transformation.py b/litellm/llms/cohere/ocr/transformation.py index dd15d5360a6..b55ff4a3cbf 100644 --- a/litellm/llms/cohere/ocr/transformation.py +++ b/litellm/llms/cohere/ocr/transformation.py @@ -144,9 +144,6 @@ class CohereParseConfig(BaseOCRConfig): def get_api_key_env_var(self) -> str | None: return COHERE_API_KEY_ENV_VAR - def supports_rust_bridge(self) -> bool: - return False - def get_health_check_document(self) -> DocumentType: return { # mutable-ok: litellm.aocr rejects any document that is not a dict "type": "image_url", diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 7fa09951eae..2d8e8071479 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -4497,7 +4497,7 @@ }, "azure/eu/o1-2024-12-17": { "cache_read_input_token_cost": 8.25e-06, - "deprecation_date": "2026-10-21", + "deprecation_date": "2026-11-19", "input_cost_per_token": 1.65e-05, "litellm_provider": "azure", "max_input_tokens": 200000, @@ -4543,7 +4543,7 @@ }, "azure/eu/o3-mini-2025-01-31": { "cache_read_input_token_cost": 6.05e-07, - "deprecation_date": "2026-10-01", + "deprecation_date": "2026-11-19", "input_cost_per_token": 1.21e-06, "input_cost_per_token_batches": 6.05e-07, "litellm_provider": "azure", @@ -8730,7 +8730,7 @@ "supports_function_calling": true }, "azure/o1": { - "deprecation_date": "2026-10-21", + "deprecation_date": "2026-11-19", "cache_read_input_token_cost": 7.5e-06, "input_cost_per_token": 1.5e-05, "litellm_provider": "azure", @@ -8748,7 +8748,7 @@ }, "azure/o1-2024-12-17": { "cache_read_input_token_cost": 7.5e-06, - "deprecation_date": "2026-10-21", + "deprecation_date": "2026-11-19", "input_cost_per_token": 1.5e-05, "litellm_provider": "azure", "max_input_tokens": 200000, @@ -8825,7 +8825,7 @@ "supports_vision": false }, "azure/o3": { - "deprecation_date": "2026-10-21", + "deprecation_date": "2026-11-19", "cache_read_input_token_cost": 5e-07, "input_cost_per_token": 2e-06, "litellm_provider": "azure", @@ -8855,7 +8855,7 @@ "supports_vision": true }, "azure/o3-2025-04-16": { - "deprecation_date": "2026-10-21", + "deprecation_date": "2026-11-19", "cache_read_input_token_cost": 5e-07, "input_cost_per_token": 2e-06, "litellm_provider": "azure", @@ -8886,7 +8886,7 @@ }, "azure/o3-deep-research": { "cache_read_input_token_cost": 2.5e-06, - "deprecation_date": "2026-12-26", + "deprecation_date": "2026-11-19", "input_cost_per_token": 1e-05, "litellm_provider": "azure", "max_input_tokens": 200000, @@ -8923,7 +8923,7 @@ "supports_web_search": true }, "azure/o3-mini": { - "deprecation_date": "2026-10-01", + "deprecation_date": "2026-11-19", "cache_read_input_token_cost": 5.5e-07, "input_cost_per_token": 1.1e-06, "litellm_provider": "azure", @@ -8940,7 +8940,7 @@ }, "azure/o3-mini-2025-01-31": { "cache_read_input_token_cost": 5.5e-07, - "deprecation_date": "2026-10-01", + "deprecation_date": "2026-11-19", "input_cost_per_token": 1.1e-06, "litellm_provider": "azure", "max_input_tokens": 200000, @@ -8954,7 +8954,7 @@ "supports_vision": false }, "azure/o3-pro": { - "deprecation_date": "2026-12-17", + "deprecation_date": "2026-11-19", "input_cost_per_token": 2e-05, "input_cost_per_token_batches": 1e-05, "litellm_provider": "azure", @@ -8985,7 +8985,7 @@ "supports_vision": true }, "azure/o3-pro-2025-06-10": { - "deprecation_date": "2026-12-17", + "deprecation_date": "2026-11-19", "input_cost_per_token": 2e-05, "input_cost_per_token_batches": 1e-05, "litellm_provider": "azure", @@ -9016,7 +9016,7 @@ "supports_vision": true }, "azure/o4-mini": { - "deprecation_date": "2026-10-16", + "deprecation_date": "2026-11-19", "cache_read_input_token_cost": 2.75e-07, "input_cost_per_token": 1.1e-06, "litellm_provider": "azure", @@ -9047,7 +9047,7 @@ }, "azure/o4-mini-2025-04-16": { "cache_read_input_token_cost": 2.75e-07, - "deprecation_date": "2026-10-16", + "deprecation_date": "2026-11-19", "input_cost_per_token": 1.1e-06, "litellm_provider": "azure", "max_input_tokens": 200000, @@ -9600,7 +9600,7 @@ }, "azure/us/o1-2024-12-17": { "cache_read_input_token_cost": 8.25e-06, - "deprecation_date": "2026-10-21", + "deprecation_date": "2026-11-19", "input_cost_per_token": 1.65e-05, "litellm_provider": "azure", "max_input_tokens": 200000, @@ -9645,7 +9645,7 @@ "supports_vision": false }, "azure/us/o3-2025-04-16": { - "deprecation_date": "2026-10-21", + "deprecation_date": "2026-11-19", "cache_read_input_token_cost": 5.5e-07, "input_cost_per_token": 2.2e-06, "litellm_provider": "azure", @@ -9676,7 +9676,7 @@ }, "azure/us/o3-mini-2025-01-31": { "cache_read_input_token_cost": 6.05e-07, - "deprecation_date": "2026-10-01", + "deprecation_date": "2026-11-19", "input_cost_per_token": 1.21e-06, "input_cost_per_token_batches": 6.05e-07, "litellm_provider": "azure", @@ -9693,7 +9693,7 @@ }, "azure/us/o4-mini-2025-04-16": { "cache_read_input_token_cost": 3.1e-07, - "deprecation_date": "2026-10-16", + "deprecation_date": "2026-11-19", "input_cost_per_token": 1.21e-06, "litellm_provider": "azure", "max_input_tokens": 200000, @@ -14615,7 +14615,7 @@ }, "computer-use-preview": { "input_cost_per_token": 3e-06, - "litellm_provider": "azure", + "litellm_provider": "openai", "max_input_tokens": 8192, "max_output_tokens": 1024, "max_tokens": 1024, @@ -14633,12 +14633,14 @@ ], "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_pdf_input": true, "supports_prompt_caching": false, "supports_reasoning": true, - "supports_response_schema": true, + "supports_response_schema": false, "supports_system_messages": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "source": "https://platform.openai.com/docs/models/computer-use-preview" }, "dall-e-2": { "deprecation_date": "2026-05-12", @@ -39019,7 +39021,9 @@ "supports_max_reasoning_effort": true, "supports_tool_choice": true, "supports_vision": true, - "prompt_cache_min_tokens": 4096 + "prompt_cache_min_tokens": 4096, + "supports_response_schema": true, + "source": "https://openrouter.ai/api/v1/models" }, "openrouter/anthropic/claude-sonnet-4.5": { "input_cost_per_image": 0.0048, @@ -39167,18 +39171,20 @@ }, "openrouter/deepseek/deepseek-v3.2": { "input_cost_per_token": 2.69e-07, - "input_cost_per_token_cache_hit": 2.8e-08, + "input_cost_per_token_cache_hit": 1.345e-07, "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": 4e-07, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "supports_response_schema": true, + "source": "https://openrouter.ai/api/v1/models" }, "openrouter/deepseek/deepseek-v3.2-exp": { "input_cost_per_token": 2.7e-07, @@ -43488,6 +43494,7 @@ "supports_tool_choice": true }, "together_ai/openai/gpt-oss-20b": { + "deprecation_date": "2026-09-14", "input_cost_per_token": 5e-08, "litellm_provider": "together_ai", "max_input_tokens": 131072, @@ -43780,6 +43787,7 @@ "source": "https://docs.together.ai/docs/serverless-models" }, "together_ai/google/gemma-4-31B-it": { + "deprecation_date": "2026-09-14", "input_cost_per_token": 3.9e-07, "litellm_provider": "together_ai", "max_input_tokens": 262144, @@ -43794,6 +43802,7 @@ "supports_vision": true }, "together_ai/intfloat/multilingual-e5-large-instruct": { + "deprecation_date": "2026-09-14", "input_cost_per_token": 2e-08, "litellm_provider": "together_ai", "max_input_tokens": 514, @@ -43906,6 +43915,7 @@ "supports_tool_choice": true }, "together_ai/thinkingmachines/Inkling-Small": { + "deprecation_date": "2026-09-14", "cache_read_input_token_cost": 1e-07, "input_cost_per_token": 5e-07, "litellm_provider": "together_ai", @@ -60832,6 +60842,7 @@ "source": "https://docs.together.ai/docs/serverless-models" }, "together_ai/moonshotai/Kimi-K2.6": { + "deprecation_date": "2026-08-19", "input_cost_per_token": 1.2e-06, "output_cost_per_token": 4.5e-06, "cache_read_input_token_cost": 2e-07, @@ -60858,6 +60869,7 @@ "source": "https://api.together.xyz/v1/models" }, "together_ai/zai-org/GLM-5": { + "deprecation_date": "2026-06-22", "input_cost_per_token": 1e-06, "output_cost_per_token": 3.2e-06, "litellm_provider": "together_ai", @@ -60866,6 +60878,7 @@ "source": "https://api.together.xyz/v1/models" }, "together_ai/zai-org/GLM-5.1": { + "deprecation_date": "2026-07-10", "input_cost_per_token": 1.4e-06, "output_cost_per_token": 4.4e-06, "cache_read_input_token_cost": 2.6e-07, @@ -60883,6 +60896,7 @@ "source": "https://api.together.xyz/v1/models" }, "together_ai/Qwen/Qwen3-Coder-Next-FP8": { + "deprecation_date": "2026-05-14", "input_cost_per_token": 5e-07, "output_cost_per_token": 1.2e-06, "litellm_provider": "together_ai", @@ -60891,6 +60905,7 @@ "source": "https://api.together.xyz/v1/models" }, "together_ai/Qwen/Qwen3-VL-32B-Instruct": { + "deprecation_date": "2026-02-25", "input_cost_per_token": 5e-07, "output_cost_per_token": 1.5e-06, "litellm_provider": "together_ai", @@ -60899,6 +60914,7 @@ "source": "https://api.together.xyz/v1/models" }, "together_ai/Qwen/Qwen3-VL-8B-Instruct": { + "deprecation_date": "2026-04-16", "input_cost_per_token": 1.8e-07, "output_cost_per_token": 6.8e-07, "litellm_provider": "together_ai", @@ -60931,6 +60947,7 @@ "source": "https://api.together.xyz/v1/models" }, "together_ai/Qwen/QwQ-32B": { + "deprecation_date": "2025-11-13", "input_cost_per_token": 1.2e-06, "output_cost_per_token": 1.2e-06, "litellm_provider": "together_ai", diff --git a/litellm/ocr/input.py b/litellm/ocr/input.py new file mode 100644 index 00000000000..bcb448371c4 --- /dev/null +++ b/litellm/ocr/input.py @@ -0,0 +1,112 @@ +from collections.abc import Mapping +from os import PathLike +from typing import Final, Literal, Protocol, cast # noqa: TID251 # native callables are validated when loaded + +from typing_extensions import NotRequired, ReadOnly, TypedDict + +from litellm.rust_bridge.bindings import NativeBinding +from litellm.rust_bridge.configuration import rust_ocr_enabled + + +class FileReader(Protocol): + def read(self) -> bytes | str: ... + + +class FileDocument(TypedDict): + type: ReadOnly[Literal["file"]] + file: ReadOnly[bytes | PathLike[str] | FileReader] + mime_type: ReadOnly[NotRequired[str]] + + +class NativeFileDocument(Protocol): + def __call__(self, document: Mapping[str, object]) -> dict[str, str]: ... + + +class NativeUploadDocument(Protocol): + def __call__(self, file_content: bytes, file_name: str | None, content_type: str | None) -> dict[str, str]: ... + + +class NativeMimeType(Protocol): + def __call__(self, file_name: str) -> str: ... + + +_FILE_DOCUMENT: Final = NativeBinding( + "_ocr_file_document", + validate=lambda value: ( + cast( # cast-ok: native export owns the callable signature + NativeFileDocument, value + ) + if callable(value) + else None + ), +) +_UPLOAD_DOCUMENT: Final = NativeBinding( + "_ocr_upload_document", + validate=lambda value: ( + cast( # cast-ok: native export owns the callable signature + NativeUploadDocument, value + ) + if callable(value) + else None + ), +) +_MAX_FILE_BYTES: Final = NativeBinding( + "_OCR_MAX_FILE_BYTES", validate=lambda value: value if isinstance(value, int) and value > 0 else None +) +_MIME_TYPE: Final = NativeBinding( + "_ocr_mime_type", + validate=lambda value: ( + cast( # cast-ok: native export owns the callable signature + NativeMimeType, value + ) + if callable(value) + else None + ), +) +_PYTHON_MAX_FILE_BYTES: Final = 50 * 1024 * 1024 + + +def get_mime_type(file_path: str) -> str: + native: Final = _MIME_TYPE.load() if rust_ocr_enabled() else None + if native is None: + from litellm.ocr import legacy + + return legacy.get_mime_type(file_path) + return native(file_path) + + +def get_max_file_bytes() -> int: + limit: Final = _MAX_FILE_BYTES.load() if rust_ocr_enabled() else None + if limit is None: + return _PYTHON_MAX_FILE_BYTES + return limit + + +def convert_file_document_to_url_document(document: FileDocument) -> dict[str, str]: + native: Final = _FILE_DOCUMENT.load() if rust_ocr_enabled() else None + if native is None: + from litellm.ocr import legacy + + return legacy.convert_file_document_to_url_document(document) + return native(document) + + +def convert_upload_to_url_document( + file_content: bytes, filename: str | None, content_type: str | None +) -> dict[str, str]: + native: Final = _UPLOAD_DOCUMENT.load() if rust_ocr_enabled() else None + if native is None: + from litellm.ocr import legacy + + if len(file_content) > _PYTHON_MAX_FILE_BYTES: + raise ValueError("OCR file exceeds the size limit") + content_mime: Final = content_type.split(";")[0].strip() if content_type else None + mime_type: Final = ( + legacy.get_mime_type(filename) + if filename and (not content_mime or content_mime == "application/octet-stream") + else content_mime or "application/octet-stream" + ) + return legacy.convert_file_document_to_url_document( + {"type": "file", "file": file_content, "mime_type": mime_type} + ) + return native(file_content, filename, content_type) diff --git a/litellm/ocr/legacy.py b/litellm/ocr/legacy.py new file mode 100644 index 00000000000..ddf6016dce3 --- /dev/null +++ b/litellm/ocr/legacy.py @@ -0,0 +1,411 @@ +""" +Main OCR function for LiteLLM. +""" + +import asyncio +import base64 +import mimetypes +import os +import re +from collections.abc import Coroutine, Mapping +from dataclasses import dataclass +from io import IOBase +from types import MappingProxyType +from typing import Final, cast # noqa: TID251 # adapters preserve the legacy untyped contracts + +import httpx + +import litellm +from litellm._logging import verbose_logger +from litellm.constants import request_timeout +from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj +from litellm.llms.base_llm.ocr.transformation import ( + OCR_REQUEST_FORMAT_PARAM, + BaseOCRConfig, + OCRResponse, + parse_ocr_request_format, +) +from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler +from litellm.ocr.input import FileReader +from litellm.types.router import GenericLiteLLMParams +from litellm.utils import ProviderConfigManager, client + +base_llm_http_handler: Final = BaseLLMHTTPHandler() + + +@dataclass(frozen=True, slots=True) +class _PreparedOCRRequest: + model: str + document: Mapping[str, object] + api_key: str | None + api_base: str | None + custom_llm_provider: str + extra_headers: dict[str, object] | None + provider_config: BaseOCRConfig + optional_params: dict[str, object] + litellm_params: dict[str, object] + effective_timeout: float | httpx.Timeout + litellm_logging_obj: LiteLLMLoggingObj + + +def _prepare_ocr_request( + model: str, + document: Mapping[str, object], + api_key: str | None, + api_base: str | None, + timeout: float | httpx.Timeout | None, + custom_llm_provider: str | None, + extra_headers: dict[str, object] | None, + kwargs: dict[str, object], +) -> _PreparedOCRRequest: + litellm_logging_obj: Final = cast( # cast-ok: @client supplies the logging object; preserve legacy failure behavior + LiteLLMLoggingObj, kwargs.pop("litellm_logging_obj") + ) + litellm_call_id: Final = cast( # cast-ok: @client supplies the call id without coercion + str | None, kwargs.get("litellm_call_id", None) + ) + + if not isinstance(document, dict): + raise ValueError(f"document must be a dict with 'type' and URL/file field, got {type(document)}") + + doc_type = document.get("type") + + if doc_type == "file": + document = convert_file_document_to_url_document(document) + doc_type = document.get("type") + + if doc_type not in ["document_url", "image_url"]: + raise ValueError(f"Invalid document type: {doc_type}. Must be 'document_url', 'image_url', or 'file'") + + ( + model, + custom_llm_provider, + dynamic_api_key, + dynamic_api_base, + ) = litellm.get_llm_provider( + model=model, + custom_llm_provider=custom_llm_provider, + api_base=api_base, + api_key=api_key, + ) + + ocr_provider_config: Final = ProviderConfigManager.get_provider_ocr_config( + model=model, + provider=litellm.LlmProviders(custom_llm_provider), + ) + + if ocr_provider_config is None: + raise ValueError(f"OCR is not supported for provider: {custom_llm_provider}") + + resolved_api_key, resolved_api_base = ocr_provider_config.resolve_connection_params( + api_key=api_key, + api_base=api_base, + dynamic_api_key=dynamic_api_key, + dynamic_api_base=dynamic_api_base, + ) + + verbose_logger.debug("OCR call - model: %s, provider: %s", model, custom_llm_provider) + + litellm_params: Final = GenericLiteLLMParams.model_validate(kwargs) + + supported_params: Final = ocr_provider_config.get_supported_ocr_params(model=model) + requested_format: Final = kwargs.get(OCR_REQUEST_FORMAT_PARAM) + if requested_format is not None: + try: + parsed_format: Final = parse_ocr_request_format(requested_format) + except ValueError as e: + raise litellm.exceptions.UnsupportedParamsError( + message=f"{e}", model=model, llm_provider=custom_llm_provider + ) from e + if OCR_REQUEST_FORMAT_PARAM not in supported_params and parsed_format == "native": + raise litellm.exceptions.UnsupportedParamsError( + message=( + f"`{OCR_REQUEST_FORMAT_PARAM}='native'` is not supported for provider: {custom_llm_provider}, " + f"model: {model}" + ), + model=model, + llm_provider=custom_llm_provider, + ) + + non_default_params: Final = {} + for param in supported_params: + if param in kwargs: + non_default_params[param] = kwargs.pop(param) + + optional_params: Final = ocr_provider_config.map_ocr_params( + non_default_params=non_default_params, + optional_params={}, + model=model, + ) + + verbose_logger.debug("OCR optional_params after mapping: %s", optional_params) + + effective_timeout: Final = timeout or request_timeout + + litellm_logging_obj.update_from_kwargs( + kwargs=kwargs, + model=model, + optional_params=optional_params, + litellm_params={ + "litellm_call_id": litellm_call_id, + "api_base": resolved_api_base, + }, + custom_llm_provider=custom_llm_provider, + ) + + return _PreparedOCRRequest( + model=model, + document=document, + api_key=resolved_api_key, + api_base=resolved_api_base, + custom_llm_provider=custom_llm_provider, + extra_headers=extra_headers, + provider_config=ocr_provider_config, + optional_params=cast( + dict[str, object], optional_params + ), # cast-ok: provider configs return heterogeneous OCR options + litellm_params=dict(litellm_params), + effective_timeout=effective_timeout, + litellm_logging_obj=litellm_logging_obj, + ) + + +def _error_provider(model: str, custom_llm_provider: str | None) -> str | None: + if custom_llm_provider is not None: + return custom_llm_provider + prefix: Final = model.partition("/")[0] + if prefix in {"mistral", "azure_ai", "vertex_ai"}: + return prefix + return "mistral" if model.startswith("mistral-ocr") else None + + +@client +async def aocr( + model: str, + document: Mapping[str, object], + api_key: str | None = None, + api_base: str | None = None, + timeout: float | httpx.Timeout | None = None, + custom_llm_provider: str | None = None, + extra_headers: dict[str, object] | None = None, + **kwargs: object, # kwargs-ok: public OCR accepts provider-specific options +) -> OCRResponse: + completion_kwargs: Final[dict[str, object]] = { + "model": model, + "document": document, + "api_key": api_key, + "api_base": api_base, + "timeout": timeout, + "custom_llm_provider": custom_llm_provider, + "extra_headers": extra_headers, + "kwargs": kwargs, + } + try: + prepared: Final = _prepare_ocr_request( + model=model, + document=document, + api_key=api_key, + api_base=api_base, + timeout=timeout, + custom_llm_provider=custom_llm_provider, + extra_headers=extra_headers, + kwargs=kwargs, + ) + model = prepared.model + custom_llm_provider = prepared.custom_llm_provider + completion_kwargs.update({"model": model, "custom_llm_provider": custom_llm_provider}) + + response = base_llm_http_handler.ocr( + model=prepared.model, + document=cast( # cast-ok: preserve legacy document fields for provider validation + dict[str, str], prepared.document + ), + optional_params=prepared.optional_params, + timeout=prepared.effective_timeout, + logging_obj=prepared.litellm_logging_obj, + api_key=prepared.api_key, + api_base=prepared.api_base, + custom_llm_provider=prepared.custom_llm_provider, + aocr=True, + headers=prepared.extra_headers, + provider_config=prepared.provider_config, + litellm_params=prepared.litellm_params, + ) + + if asyncio.iscoroutine(response): + response = await response + + if response is None: + raise ValueError(f"Got an unexpected None response from the OCR API: {response}") + + return response + except Exception as e: + error_provider: Final = _error_provider(model, custom_llm_provider) + error_model: Final = model.removeprefix(f"{error_provider}/") if error_provider else model + raise litellm.exception_type( + model=error_model, + custom_llm_provider=error_provider, + original_exception=e, + completion_kwargs=completion_kwargs, + extra_kwargs=kwargs, + ) + + +_MIME_PATTERN: Final = re.compile(r"^[\w.+-]+/[\w.+-]+$") + +_MIME_TYPE_MAP: Final = MappingProxyType( + { + ".pdf": "application/pdf", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".tiff": "image/tiff", + ".tif": "image/tiff", + ".bmp": "image/bmp", + } +) + + +def get_mime_type(file_path: str) -> str: + ext: Final = os.path.splitext(file_path)[1].lower() + mime: Final = _MIME_TYPE_MAP.get(ext) + if mime: + return mime + guessed, _ = mimetypes.guess_type(file_path) + return guessed or "application/octet-stream" + + +def _read_file(file_input: object) -> tuple[bytes, str, str | None]: + if isinstance(file_input, str): + raise ValueError( + "OCR file input does not accept bare str values. Pass bytes, " + "a pathlib.Path, or a file-like object. To OCR a local file " + "from a path, call open(path, 'rb') yourself." + ) + if isinstance(file_input, os.PathLike): + file_path: Final = str(cast(object, file_input)) # cast-ok: preserve staging's str(PathLike) conversion + if not os.path.isfile(file_path): + raise FileNotFoundError(f"File not found: {file_path}") + mime_type: Final = get_mime_type(file_path) + with open(file_path, "rb") as stream: + return stream.read(), mime_type, os.path.basename(file_path) + if isinstance(file_input, bytes): + return file_input, "application/octet-stream", None + if isinstance(file_input, IOBase) or hasattr(file_input, "read"): + file_name: Final = cast( # cast-ok: retain legacy validation and errors for file-like metadata + str | None, getattr(file_input, "name", None) + ) + inferred_mime: Final = get_mime_type(file_name) if file_name else "application/octet-stream" + reader: Final = cast(FileReader, file_input) # cast-ok: legacy accepts duck-typed file readers + content: Final = reader.read() + return content.encode("utf-8") if isinstance(content, str) else content, inferred_mime, file_name + raise ValueError( + f"Unsupported file input type: {type(file_input)}. Expected pathlib.Path, bytes, or a file-like object." + ) + + +def convert_file_document_to_url_document(document: Mapping[str, object]) -> dict[str, str]: + file_input: Final = document.get("file") + if file_input is None: + raise ValueError( + "document with type='file' must include a 'file' field containing " + "a pathlib.Path, file-like object, or bytes" + ) + file_bytes, inferred_mime, file_name = _read_file(file_input) + if not file_bytes: + raise ValueError("File is empty or could not be read") + mime_type: Final = cast( # cast-ok: keep staging's MIME validation errors + str, document.get("mime_type", inferred_mime) + ) + if not _MIME_PATTERN.match(mime_type): + raise ValueError(f"Invalid MIME type: {mime_type}") + + base64_data: Final = base64.b64encode(file_bytes).decode("utf-8") + data_uri: Final = f"data:{mime_type};base64,{base64_data}" + + if mime_type.startswith("image/"): + verbose_logger.debug( + "OCR file input: Converted file to image_url data URI (mime=%s, size=%s bytes, name=%s)", + mime_type, + len(file_bytes), + file_name, + ) + return {"type": "image_url", "image_url": data_uri} + + verbose_logger.debug( + "OCR file input: Converted file to document_url data URI (mime=%s, size=%s bytes, name=%s)", + mime_type, + len(file_bytes), + file_name, + ) + return {"type": "document_url", "document_url": data_uri} + + +@client +def ocr( + model: str, + document: Mapping[str, object], + api_key: str | None = None, + api_base: str | None = None, + timeout: float | httpx.Timeout | None = None, + custom_llm_provider: str | None = None, + extra_headers: dict[str, object] | None = None, + **kwargs: object, # kwargs-ok: public OCR accepts provider-specific options +) -> OCRResponse | Coroutine[object, object, OCRResponse]: + completion_kwargs: Final[dict[str, object]] = { + "model": model, + "document": document, + "api_key": api_key, + "api_base": api_base, + "timeout": timeout, + "custom_llm_provider": custom_llm_provider, + "extra_headers": extra_headers, + "kwargs": kwargs, + } + try: + _is_async: Final = kwargs.pop("aocr", False) is True + completion_kwargs["aocr"] = _is_async + prepared: Final = _prepare_ocr_request( + model=model, + document=document, + api_key=api_key, + api_base=api_base, + kwargs=kwargs, + custom_llm_provider=custom_llm_provider, + extra_headers=extra_headers, + timeout=timeout, + ) + model = prepared.model + custom_llm_provider = prepared.custom_llm_provider + completion_kwargs.update({"model": model, "custom_llm_provider": custom_llm_provider}) + + response: Final = base_llm_http_handler.ocr( + model=prepared.model, + document=cast( # cast-ok: preserve legacy document fields for provider validation + dict[str, str], prepared.document + ), + optional_params=prepared.optional_params, + timeout=prepared.effective_timeout, + logging_obj=prepared.litellm_logging_obj, + api_key=prepared.api_key, + api_base=prepared.api_base, + custom_llm_provider=prepared.custom_llm_provider, + aocr=_is_async, + headers=prepared.extra_headers, + provider_config=prepared.provider_config, + litellm_params=prepared.litellm_params, + ) + + return response + except Exception as e: + error_provider: Final = _error_provider(model, custom_llm_provider) + error_model: Final = model.removeprefix(f"{error_provider}/") if error_provider else model + raise litellm.exception_type( + model=error_model, + custom_llm_provider=error_provider, + original_exception=e, + completion_kwargs=completion_kwargs, + extra_kwargs=kwargs, + ) diff --git a/litellm/ocr/main.py b/litellm/ocr/main.py index 56bfd98895d..382c5d6aae4 100644 --- a/litellm/ocr/main.py +++ b/litellm/ocr/main.py @@ -1,460 +1,20 @@ -""" -Main OCR function for LiteLLM. -""" - -import asyncio -import base64 -import mimetypes -import os -import re -from collections.abc import Callable, Coroutine, Mapping -from dataclasses import dataclass -from io import IOBase -from types import MappingProxyType -from typing import Any, Final, cast +from collections.abc import Awaitable, Callable, Coroutine, Mapping +from typing import Final, cast # noqa: TID251 # native binding selects a sync result or an async awaitable import httpx -import litellm -from litellm._logging import verbose_logger -from litellm.constants import request_timeout -from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj -from litellm.llms.azure_ai.ocr.common_utils import ( - is_azure_cohere_parse_model, - is_azure_document_intelligence_model, -) -from litellm.llms.base_llm.ocr.transformation import ( - OCR_REQUEST_FORMAT_PARAM, - BaseOCRConfig, - OCRResponse, - parse_ocr_request_format, -) -from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler -from litellm.rust_bridge import ocr as rust_ocr_bridge +from litellm.llms.base_llm.ocr.transformation import OCRResponse +from litellm.ocr import legacy +from litellm.ocr.input import convert_file_document_to_url_document, get_mime_type from litellm.rust_bridge.bindings import native_exception_types -from litellm.rust_bridge.configuration import rust_enabled -from litellm.types.router import GenericLiteLLMParams -from litellm.utils import ProviderConfigManager, client +from litellm.rust_bridge.configuration import rust_ocr_enabled +from litellm.rust_bridge.ocr import LiteLLMOcrRequest +from litellm.rust_bridge.ocr_lifecycle import select -####### ENVIRONMENT VARIABLES ################### -base_llm_http_handler = BaseLLMHTTPHandler() -################################################# +__all__ = ("aocr", "convert_file_document_to_url_document", "get_mime_type", "ocr") -@dataclass -class _PreparedOCRRequest: - model: str - document: dict[str, Any] - api_key: str | None - api_base: str | None - custom_llm_provider: str - extra_headers: dict[str, object] | None - provider_config: BaseOCRConfig - optional_params: dict[str, object] - litellm_params: dict[str, object] - effective_timeout: float | httpx.Timeout - litellm_logging_obj: LiteLLMLoggingObj - caller_supplied_api_key: bool = True - caller_supplied_api_base: bool = True - - -_RUST_OCR_PROVIDERS: Final = frozenset({"mistral", "azure_ai", "vertex_ai"}) -_RUST_OCR_CONFIG_FIELDS: Final = frozenset( - { - "azure_ad_token", - "tenant_id", - "client_id", - "client_secret", - "azure_scope", - "azure_authority_host", - "azure_credential", - "azure_federated_token_file", - "vertex_credentials", - "vertex_ai_credentials", - "vertex_project", - "vertex_ai_project", - "vertex_location", - "vertex_ai_location", - } -) -_RUST_OCR_SECRET_FIELDS: Final = frozenset( - {"azure_ad_token", "client_secret", "azure_federated_token_file", "vertex_credentials", "vertex_ai_credentials"} -) - - -def _prepare_ocr_request( - model: str, - document: Mapping[str, object], - api_key: str | None, - api_base: str | None, - timeout: float | httpx.Timeout | None, - custom_llm_provider: str | None, - extra_headers: dict[str, object] | None, - kwargs: dict[str, object], -) -> _PreparedOCRRequest: - litellm_logging_obj: Final = cast(LiteLLMLoggingObj, kwargs.pop("litellm_logging_obj")) - litellm_call_id: Final = cast(str | None, kwargs.get("litellm_call_id", None)) - - if not isinstance(document, dict): - raise ValueError(f"document must be a dict with 'type' and URL/file field, got {type(document)}") - - doc_type = document.get("type") - - if doc_type == "file": - document = convert_file_document_to_url_document(document) - doc_type = document.get("type") - - if doc_type not in ["document_url", "image_url"]: - raise ValueError(f"Invalid document type: {doc_type}. Must be 'document_url', 'image_url', or 'file'") - - caller_supplied_api_key: Final = api_key is not None - caller_supplied_api_base: Final = api_base is not None - - ( - model, - custom_llm_provider, - dynamic_api_key, - dynamic_api_base, - ) = litellm.get_llm_provider( - model=model, - custom_llm_provider=custom_llm_provider, - api_base=api_base, - api_key=api_key, - ) - - suppress_dynamic_api_base: Final = ( - not caller_supplied_api_base - and custom_llm_provider == "azure_ai" - and is_azure_document_intelligence_model(model) - ) - if dynamic_api_key: - api_key = dynamic_api_key - if dynamic_api_base and not suppress_dynamic_api_base: - api_base = dynamic_api_base - - ocr_provider_config: Final = ProviderConfigManager.get_provider_ocr_config( - model=model, - provider=litellm.LlmProviders(custom_llm_provider), - ) - - if ocr_provider_config is None: - raise ValueError(f"OCR is not supported for provider: {custom_llm_provider}") - - verbose_logger.debug("OCR call - model: %s, provider: %s", model, custom_llm_provider) - - litellm_params: Final = GenericLiteLLMParams.model_validate(kwargs) - - supported_params: Final = ocr_provider_config.get_supported_ocr_params(model=model) - requested_format: Final = kwargs.get(OCR_REQUEST_FORMAT_PARAM) - if requested_format is not None: - try: - parsed_format: Final = parse_ocr_request_format(requested_format) - except ValueError as e: - raise litellm.exceptions.UnsupportedParamsError( - message=f"{e}", model=model, llm_provider=custom_llm_provider - ) from e - if OCR_REQUEST_FORMAT_PARAM not in supported_params and parsed_format == "native": - raise litellm.exceptions.UnsupportedParamsError( - message=( - f"`{OCR_REQUEST_FORMAT_PARAM}='native'` is not supported for provider: {custom_llm_provider}, " - f"model: {model}" - ), - model=model, - llm_provider=custom_llm_provider, - ) - - non_default_params: Final = {} - for param in supported_params: - if param in kwargs: - non_default_params[param] = kwargs.pop(param) - - optional_params: Final = ocr_provider_config.map_ocr_params( - non_default_params=non_default_params, - optional_params={}, - model=model, - ) - - verbose_logger.debug("OCR optional_params after mapping: %s", optional_params) - - effective_timeout: Final = timeout or request_timeout - - litellm_logging_obj.update_from_kwargs( - kwargs=kwargs, - model=model, - optional_params=optional_params, - litellm_params={ - "litellm_call_id": litellm_call_id, - "api_base": api_base, - }, - custom_llm_provider=custom_llm_provider, - ) - - return _PreparedOCRRequest( - model=model, - document=document, - api_key=api_key, - api_base=api_base, - custom_llm_provider=custom_llm_provider, - extra_headers=extra_headers, - provider_config=ocr_provider_config, - optional_params=cast(dict[str, object], optional_params), - litellm_params=dict(litellm_params), - effective_timeout=effective_timeout, - litellm_logging_obj=litellm_logging_obj, - caller_supplied_api_key=caller_supplied_api_key, - caller_supplied_api_base=caller_supplied_api_base, - ) - - -def _rust_ocr_provider(request: rust_ocr_bridge.LiteLLMOcrRequest) -> str | None: - if request.custom_llm_provider is not None: - return request.custom_llm_provider - prefix: Final = request.model.partition("/")[0] - if prefix in _RUST_OCR_PROVIDERS: - return prefix - if request.model.startswith("mistral-ocr"): - return "mistral" - return None - - -def _rust_ocr_supported(request: rust_ocr_bridge.LiteLLMOcrRequest) -> bool: - provider: Final = _rust_ocr_provider(request) - if provider not in _RUST_OCR_PROVIDERS or request.kwargs.get(OCR_REQUEST_FORMAT_PARAM) == "native": - return False - if provider == "azure_ai": - return ( - not is_azure_cohere_parse_model(request.model) - and not callable(request.kwargs.get("azure_ad_token_provider")) - and request.kwargs.get("azure_username") is None - and request.kwargs.get("azure_password") is None - ) - return True - - -def _rust_bridge_optional_params( - request: rust_ocr_bridge.LiteLLMOcrRequest, - resolve_secret: Callable[[str], str | None], -) -> Mapping[str, object]: - optional_params: Final = MappingProxyType( - { - name: value - for name, value in request.kwargs.items() - if (name not in GenericLiteLLMParams.model_fields or name in _RUST_OCR_CONFIG_FIELDS) - and name not in {"litellm_logging_obj", "aocr", "litellm_call_id", "proxy_server_request"} - } - ) - provider: Final = _rust_ocr_provider(request) - if provider == "azure_ai" and litellm.enable_azure_ad_token_refresh is True: - return MappingProxyType({**optional_params, "enable_azure_ad_token_refresh": True}) - if provider != "vertex_ai": - return optional_params - project: Final = ( - request.kwargs.get("vertex_project") - or request.kwargs.get("vertex_ai_project") - or litellm.vertex_project - or resolve_secret("VERTEXAI_PROJECT") - ) - location: Final = ( - request.kwargs.get("vertex_location") - or request.kwargs.get("vertex_ai_location") - or litellm.vertex_location - or resolve_secret("VERTEXAI_LOCATION") - or resolve_secret("VERTEX_LOCATION") - ) - credentials: Final = ( - request.kwargs.get("vertex_credentials") - or request.kwargs.get("vertex_ai_credentials") - or resolve_secret("VERTEXAI_CREDENTIALS") - ) - vertex_params: Final = MappingProxyType( - { - name: value - for name, value in ( - ("vertex_project", project), - ("vertex_location", location), - ("vertex_credentials", credentials), - ) - if value is not None - } - ) - return MappingProxyType({**optional_params, **vertex_params}) - - -def _rust_bridge_input_sources( - request: rust_ocr_bridge.LiteLLMOcrRequest, - optional_params: Mapping[str, object], -) -> Mapping[str, str]: - proxy_request: Final = request.kwargs.get("proxy_server_request") - if not isinstance(proxy_request, Mapping): - return MappingProxyType({}) - proxy_request_mapping: Final = cast( # cast-ok: runtime Mapping check loses generic key and value types - Mapping[object, object], proxy_request - ) - body_value: Final = proxy_request_mapping.get("body") - if not isinstance(body_value, Mapping): - return MappingProxyType({}) - body: Final = cast( # cast-ok: runtime Mapping check loses generic key and value types - Mapping[object, object], body_value - ) - credential_fields_value: Final = proxy_request_mapping.get("credential_fields", ()) - credential_fields: Final = ( - frozenset(name for name in credential_fields_value if isinstance(name, str)) - if isinstance(credential_fields_value, (list, tuple, set, frozenset)) - else frozenset() - ) - names: Final = frozenset(optional_params) | frozenset({"api_key", "api_base", "extra_headers"}) - request_sources: Final = MappingProxyType( - {name: "request" for name in names if name in body or name in credential_fields} - ) - if litellm.enable_azure_ad_token_refresh is True and "enable_azure_ad_token_refresh" in optional_params: - return MappingProxyType({**request_sources, "enable_azure_ad_token_refresh": "deployment"}) - return request_sources - - -def _marshal_rust_ocr_request( - request: rust_ocr_bridge.LiteLLMOcrRequest, - resolve_secret: Callable[[str], str | None], -) -> rust_ocr_bridge.LiteLLMOcrRequest: - if not isinstance(request.document, dict): - raise TypeError(f"document must be a dict with 'type' and URL/file field, got {type(request.document)}") - document: Final = ( - convert_file_document_to_url_document(request.document) - if request.document.get("type") == "file" - else request.document - ) - provider: Final = _rust_ocr_provider(request) - api_key: Final = request.api_key or resolve_secret("MISTRAL_API_KEY") if provider == "mistral" else request.api_key - optional_params: Final = _rust_bridge_optional_params(request, resolve_secret) - input_sources: Final = _rust_bridge_input_sources(request, optional_params) - logged_optional_params: Final = MappingProxyType( - {name: "****" if name in _RUST_OCR_SECRET_FIELDS else value for name, value in optional_params.items()} - ) - logged_kwargs: Final = MappingProxyType( - { - name: "****" if name in _RUST_OCR_SECRET_FIELDS else value - for name, value in request.kwargs.items() - if name != "proxy_server_request" - } - ) - logging_obj: Final = cast( # cast-ok: bridge kwargs carry the prepared logging object - LiteLLMLoggingObj, request.kwargs["litellm_logging_obj"] - ) - logging_obj.update_from_kwargs( - kwargs=dict(logged_kwargs), # mutable-ok: logging API requires an owned dict - model=request.model, - optional_params=dict(logged_optional_params), # mutable-ok: logging API requires an owned dict - litellm_params={ - "litellm_call_id": request.kwargs.get("litellm_call_id"), - "api_base": request.api_base, - }, # mutable-ok: legacy logging requires a concrete params dict - custom_llm_provider=provider, - ) - logging_obj.pre_call( - input="OCR document processing", - api_key=api_key, - additional_args={ # mutable-ok: pre_call mutates the additional_args dict - "complete_input_dict": { - "model": request.model, - "document": document, - **logged_optional_params, - }, # mutable-ok: callbacks consume a JSON-serializable request dict - "api_base": request.api_base or "", - "headers": request.extra_headers or {}, # mutable-ok: logging callbacks consume a concrete headers dict - }, - ) - return rust_ocr_bridge.LiteLLMOcrRequest( - model=request.model, - document=document, - api_key=api_key, - api_base=request.api_base, - timeout=request.timeout if request.timeout is not None else request_timeout, - custom_llm_provider=request.custom_llm_provider, - extra_headers=request.extra_headers, - kwargs=optional_params, - input_sources=input_sources, - ) - - -def _map_rust_ocr_error( - error: Exception, - request: rust_ocr_bridge.LiteLLMOcrRequest, - exception_types: tuple[type[BaseException], type[BaseException]] | None, -) -> Exception: - if exception_types is None or not isinstance(error, exception_types[1]): - return error - provider: Final = _rust_ocr_provider(request) - if provider is None: - return error - provider_config: Final = ProviderConfigManager.get_provider_ocr_config( - model=request.model.removeprefix(f"{provider}/"), provider=litellm.LlmProviders(provider) - ) - if provider_config is None: - return error - error_args: Final = cast( # cast-ok: Python exceptions expose positional args as a tuple - tuple[object, ...], error.args - ) - status: Final = error_args[0] if error_args and isinstance(error_args[0], int) else 500 - message: Final = str(error_args[1]) if len(error_args) > 1 else str(error) - error_factory: Final = cast( # cast-ok: provider configs expose heterogeneous exception factories - Callable[..., Exception], provider_config.get_error_class - ) - return error_factory( - error_message=message, status_code=status or 500, headers={} - ) # mutable-ok: provider error factories require a concrete headers dict - - -def _run_rust_ocr( - request: rust_ocr_bridge.LiteLLMOcrRequest, - resolve_api_key: Callable[[str], str | None], -) -> OCRResponse | None: - if rust_ocr_bridge.load_rust_ocr() is None: - return None - marshalled: Final = _marshal_rust_ocr_request(request, resolve_api_key) - input_sources: Final = marshalled.input_sources - try: - response: Final = rust_ocr_bridge.ocr( - model=marshalled.model, - document=dict(marshalled.document), # mutable-ok: PyO3 OCR binding requires a concrete dict - api_key=marshalled.api_key, - api_base=marshalled.api_base, - custom_llm_provider=marshalled.custom_llm_provider, - extra_headers=marshalled.extra_headers, - optional_params=dict(marshalled.kwargs), # mutable-ok: PyO3 OCR binding requires a concrete dict - input_sources=input_sources, - timeout=marshalled.timeout, - ) - except Exception as error: - raise _map_rust_ocr_error(error, request, native_exception_types()) from error - return OCRResponse.model_validate(response) if response is not None else None - - -async def _run_rust_aocr( - request: rust_ocr_bridge.LiteLLMOcrRequest, - resolve_api_key: Callable[[str], str | None], -) -> OCRResponse | None: - if rust_ocr_bridge.load_rust_aocr() is None: - return None - marshalled: Final = _marshal_rust_ocr_request(request, resolve_api_key) - input_sources: Final = marshalled.input_sources - try: - response: Final = await rust_ocr_bridge.aocr( - model=marshalled.model, - document=dict(marshalled.document), # mutable-ok: PyO3 OCR binding requires a concrete dict - api_key=marshalled.api_key, - api_base=marshalled.api_base, - custom_llm_provider=marshalled.custom_llm_provider, - extra_headers=marshalled.extra_headers, - optional_params=dict(marshalled.kwargs), # mutable-ok: PyO3 OCR binding requires a concrete dict - input_sources=input_sources, - timeout=marshalled.timeout, - ) - except Exception as error: - raise _map_rust_ocr_error(error, request, native_exception_types()) from error - return OCRResponse.model_validate(response) if response is not None else None - - -@client -async def aocr( +def _bind_request( model: str, document: Mapping[str, object], api_key: str | None = None, @@ -462,77 +22,9 @@ async def aocr( timeout: float | httpx.Timeout | None = None, custom_llm_provider: str | None = None, extra_headers: dict[str, object] | None = None, - **kwargs: object, -) -> OCRResponse: - """ - Async OCR function. - - Args: - model: Model name (e.g., "mistral/mistral-ocr-latest") - document: Document to process in Mistral format: - {"type": "document_url", "document_url": "https://..."} for PDFs/docs, - {"type": "image_url", "image_url": "https://..."} for images, or - {"type": "file", "file": } for local files - api_key: Optional API key - api_base: Optional API base URL - timeout: Optional timeout - custom_llm_provider: Optional custom LLM provider - extra_headers: Optional extra headers - **kwargs: Additional parameters (e.g., include_image_base64, pages, image_limit) - - Returns: - OCRResponse in Mistral OCR format with pages, model, usage_info, etc. - - Example: - ```python - import litellm - - # OCR with PDF - response = await litellm.aocr( - model="mistral/mistral-ocr-latest", - document={ - "type": "document_url", - "document_url": "https://arxiv.org/pdf/2201.04234" - }, - include_image_base64=True - ) - - # OCR with image - response = await litellm.aocr( - model="mistral/mistral-ocr-latest", - document={ - "type": "image_url", - "image_url": "https://example.com/image.png" - } - ) - - # OCR with base64 encoded PDF - response = await litellm.aocr( - model="mistral/mistral-ocr-latest", - document={ - "type": "document_url", - "document_url": f"data:application/pdf;base64,{base64_pdf}" - } - ) - - # OCR with local file - response = await litellm.aocr( - model="mistral/mistral-ocr-latest", - document={"type": "file", "file": "/path/to/document.pdf"} - ) - ``` - """ - completion_kwargs: Final[dict[str, object]] = { - "model": model, - "document": document, - "api_key": api_key, - "api_base": api_base, - "timeout": timeout, - "custom_llm_provider": custom_llm_provider, - "extra_headers": extra_headers, - "kwargs": kwargs, - } - request: Final = rust_ocr_bridge.LiteLLMOcrRequest( + **kwargs: object, # kwargs-ok: public OCR accepts provider-specific options +) -> LiteLLMOcrRequest: + return LiteLLMOcrRequest( model=model, document=document, api_key=api_key, @@ -542,340 +34,50 @@ async def aocr( extra_headers=extra_headers, kwargs=kwargs, ) + + +def _public_request(name: str, args: tuple[object, ...], kwargs: dict[str, object]) -> LiteLLMOcrRequest: try: - if rust_enabled() and _rust_ocr_supported(request): - from litellm.secret_managers.main import get_secret_str - - rust_response: Final = await _run_rust_aocr( - request=request, - resolve_api_key=get_secret_str, - ) - if rust_response is None: - verbose_logger.debug("Async Rust OCR bridge unavailable; falling back to Python path") - else: - return rust_response - - prepared: Final = _prepare_ocr_request( - model=model, - document=document, - api_key=api_key, - api_base=api_base, - timeout=timeout, - custom_llm_provider=custom_llm_provider, - extra_headers=extra_headers, - kwargs=kwargs, - ) - model = prepared.model - custom_llm_provider = prepared.custom_llm_provider - completion_kwargs.update({"model": model, "custom_llm_provider": custom_llm_provider}) - - response = base_llm_http_handler.ocr( - model=prepared.model, - document=prepared.document, - optional_params=prepared.optional_params, - timeout=prepared.effective_timeout, - logging_obj=prepared.litellm_logging_obj, - api_key=prepared.api_key, - api_base=prepared.api_base, - custom_llm_provider=prepared.custom_llm_provider, - aocr=True, - headers=prepared.extra_headers, - provider_config=prepared.provider_config, - litellm_params=prepared.litellm_params, - ) - - if asyncio.iscoroutine(response): - response = await response - - if response is None: - raise ValueError(f"Got an unexpected None response from the OCR API: {response}") - - return response - except Exception as e: - error_provider: Final = custom_llm_provider or _rust_ocr_provider(request) - error_model: Final = model.removeprefix(f"{error_provider}/") if error_provider else model - raise litellm.exception_type( - model=error_model, - custom_llm_provider=error_provider, - original_exception=e, - completion_kwargs=completion_kwargs, - extra_kwargs=kwargs, - ) + return _bind_request(*args, **kwargs) # pyright: ignore[reportArgumentType] # Python binds the public arguments before native validation + except TypeError as error: + raise TypeError(str(error).replace("_bind_request()", f"{name}()")) from None -################################################# -# Public utilities — used by the SDK and the proxy -################################################# - -_MIME_PATTERN: Final = re.compile(r"^[\w.+-]+/[\w.+-]+$") - -_MIME_TYPE_MAP: Final = { - ".pdf": "application/pdf", - ".png": "image/png", - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".gif": "image/gif", - ".webp": "image/webp", - ".tiff": "image/tiff", - ".tif": "image/tiff", - ".bmp": "image/bmp", -} - - -def get_mime_type(file_path: str) -> str: - """ - Determine MIME type from file path extension. - - Falls back to mimetypes.guess_type, then to 'application/octet-stream'. - """ - ext: Final = os.path.splitext(file_path)[1].lower() - mime: Final = _MIME_TYPE_MAP.get(ext) - if mime: - return mime - guessed, _ = mimetypes.guess_type(file_path) - return guessed or "application/octet-stream" - - -def convert_file_document_to_url_document(document: dict[str, Any]) -> dict[str, str]: - """ - Convert a file-type document dict to a document_url-type document dict - with an inline base64 data URI. - - Accepts document dicts like: - {"type": "file", "file": Path("/path/to/doc.pdf")} # pathlib.Path - {"type": "file", "file": } # file-like object (BinaryIO) - {"type": "file", "file": b"raw bytes"} # raw bytes - - Bare ``str`` paths are not accepted — pass a ``pathlib.Path`` or - ``open(path, "rb")`` instead. See the str check below for the rationale. - - Returns: - {"type": "document_url", "document_url": "data:;base64,"} - or {"type": "image_url", "image_url": "data:;base64,"} - """ - file_input: Final = document.get("file") - if file_input is None: - raise ValueError( - "document with type='file' must include a 'file' field containing " - "a pathlib.Path, file-like object, or bytes" - ) - - file_bytes: bytes - mime_type: str = "application/octet-stream" - file_name: str | None = None - - if isinstance(file_input, str): - # Bare strings are rejected here. The OCR ``document`` accepts a - # ``{"type": "file", "file": }`` shape, and when this helper - # runs in a proxy request handler ```` is attacker-controlled. - # Opening it as a path is an arbitrary local file read on the proxy - # host, which is then base64-encoded and forwarded to the OCR - # provider — an exfiltration primitive. - raise ValueError( - "OCR file input does not accept bare str values. Pass bytes, " - "a pathlib.Path, or a file-like object. To OCR a local file " - "from a path, call open(path, 'rb') yourself." - ) - if isinstance(file_input, os.PathLike): - # os.PathLike (pathlib.Path and custom __fspath__ classes) is a - # Python-level type that HTTP form values can't fabricate. - file_path: Final = str(file_input) - if not os.path.isfile(file_path): - raise FileNotFoundError(f"File not found: {file_path}") - mime_type = get_mime_type(file_path) - file_name = os.path.basename(file_path) - with open(file_path, "rb") as f: - file_bytes = f.read() - elif isinstance(file_input, bytes): - file_bytes = file_input - elif isinstance(file_input, IOBase) or hasattr(file_input, "read"): - if hasattr(file_input, "name"): - file_name = getattr(file_input, "name", None) - if file_name: - mime_type = get_mime_type(file_name) - file_bytes = file_input.read() - if isinstance(file_bytes, str): - file_bytes = file_bytes.encode("utf-8") - else: - raise ValueError( - f"Unsupported file input type: {type(file_input)}. Expected pathlib.Path, bytes, or a file-like object." - ) - - if not file_bytes: - raise ValueError("File is empty or could not be read") - - if "mime_type" in document: - mime_type = document["mime_type"] - - if not _MIME_PATTERN.match(mime_type): - raise ValueError(f"Invalid MIME type: {mime_type}") - - base64_data: Final = base64.b64encode(file_bytes).decode("utf-8") - data_uri: Final = f"data:{mime_type};base64,{base64_data}" - - if mime_type.startswith("image/"): - verbose_logger.debug( - "OCR file input: Converted file to image_url data URI (mime=%s, size=%s bytes, name=%s)", - mime_type, - len(file_bytes), - file_name, - ) - return {"type": "image_url", "image_url": data_uri} - - verbose_logger.debug( - "OCR file input: Converted file to document_url data URI (mime=%s, size=%s bytes, name=%s)", - mime_type, - len(file_bytes), - file_name, - ) - return {"type": "document_url", "document_url": data_uri} - - -@client def ocr( - model: str, - document: Mapping[str, object], - api_key: str | None = None, - api_base: str | None = None, - timeout: float | httpx.Timeout | None = None, - custom_llm_provider: str | None = None, - extra_headers: dict[str, object] | None = None, - **kwargs: object, + *args: object, + **kwargs: object, # kwargs-ok: preserve the public OCR call shape ) -> OCRResponse | Coroutine[object, object, OCRResponse]: - """ - Synchronous OCR function. - - Args: - model: Model name (e.g., "mistral/mistral-ocr-latest") - document: Document to process in Mistral format: - {"type": "document_url", "document_url": "https://..."} for PDFs/docs, - {"type": "image_url", "image_url": "https://..."} for images, or - {"type": "file", "file": } for local files - api_key: Optional API key - api_base: Optional API base URL - timeout: Optional timeout - custom_llm_provider: Optional custom LLM provider - extra_headers: Optional extra headers - **kwargs: Additional parameters (e.g., include_image_base64, pages, image_limit) - - Returns: - OCRResponse in Mistral OCR format with pages, model, usage_info, etc. - - Example: - ```python - import litellm - - # OCR with PDF - response = litellm.ocr( - model="mistral/mistral-ocr-latest", - document={ - "type": "document_url", - "document_url": "https://arxiv.org/pdf/2201.04234" - }, - include_image_base64=True - ) - - # OCR with image - response = litellm.ocr( - model="mistral/mistral-ocr-latest", - document={ - "type": "image_url", - "image_url": "https://example.com/image.png" - } - ) - - # OCR with base64 encoded PDF - response = litellm.ocr( - model="mistral/mistral-ocr-latest", - document={ - "type": "document_url", - "document_url": f"data:application/pdf;base64,{base64_pdf}" - } - ) - - # OCR with local file - response = litellm.ocr( - model="mistral/mistral-ocr-latest", - document={"type": "file", "file": "/path/to/document.pdf"} - ) - - # Access pages - for page in response.pages: - print(f"Page {page.index}: {page.markdown}") - ``` - """ - completion_kwargs: Final[dict[str, object]] = { - "model": model, - "document": document, - "api_key": api_key, - "api_base": api_base, - "timeout": timeout, - "custom_llm_provider": custom_llm_provider, - "extra_headers": extra_headers, - "kwargs": kwargs, - } - request: Final = rust_ocr_bridge.LiteLLMOcrRequest( - model=model, - document=document, - api_key=api_key, - api_base=api_base, - timeout=timeout, - custom_llm_provider=custom_llm_provider, - extra_headers=extra_headers, - kwargs=kwargs, - ) - try: - _is_async: Final = kwargs.pop("aocr", False) is True - completion_kwargs["aocr"] = _is_async - if rust_enabled() and _rust_ocr_supported(request): - from litellm.secret_managers.main import get_secret_str - - rust_response: Final = _run_rust_ocr( - request=request, - resolve_api_key=get_secret_str, + request: Final = _public_request("ocr", args, kwargs) + native: Final = select(request) if rust_ocr_enabled() else None + if native is not None: + try: + return cast( # cast-ok: False selects the synchronous result + OCRResponse, native(request, args, kwargs, False) ) - if rust_response is None: - verbose_logger.debug("Rust OCR bridge unavailable; falling back to Python path") - else: - return rust_response + except _decline_types(): + pass + fallback: Final = cast( # cast-ok: forward the original call shape through the legacy @client decorator + Callable[..., OCRResponse | Coroutine[object, object, OCRResponse]], legacy.ocr + ) + return fallback(*args, **kwargs) - prepared: Final = _prepare_ocr_request( - model=model, - document=document, - api_key=api_key, - api_base=api_base, - kwargs=kwargs, - custom_llm_provider=custom_llm_provider, - extra_headers=extra_headers, - timeout=timeout, - ) - model = prepared.model - custom_llm_provider = prepared.custom_llm_provider - completion_kwargs.update({"model": model, "custom_llm_provider": custom_llm_provider}) - response: Final = base_llm_http_handler.ocr( - model=prepared.model, - document=prepared.document, - optional_params=prepared.optional_params, - timeout=prepared.effective_timeout, - logging_obj=prepared.litellm_logging_obj, - api_key=prepared.api_key, - api_base=prepared.api_base, - custom_llm_provider=prepared.custom_llm_provider, - aocr=_is_async, - headers=prepared.extra_headers, - provider_config=prepared.provider_config, - litellm_params=prepared.litellm_params, - ) +async def aocr(*args: object, **kwargs: object) -> OCRResponse: # kwargs-ok: preserve the public OCR call shape + request: Final = _public_request("aocr", args, kwargs) + native: Final = select(request) if rust_ocr_enabled() else None + if native is not None: + try: + return await cast( # cast-ok: True selects the asynchronous result + Awaitable[OCRResponse], native(request, args, kwargs, True) + ) + except _decline_types(): + pass + fallback: Final = cast( # cast-ok: forward the original call shape through the legacy @client decorator + Callable[..., Awaitable[OCRResponse]], legacy.aocr + ) + return await fallback(*args, **kwargs) - return response - except Exception as e: - error_provider: Final = custom_llm_provider or _rust_ocr_provider(request) - error_model: Final = model.removeprefix(f"{error_provider}/") if error_provider else model - raise litellm.exception_type( - model=error_model, - custom_llm_provider=error_provider, - original_exception=e, - completion_kwargs=completion_kwargs, - extra_kwargs=kwargs, - ) + +def _decline_types() -> tuple[type[BaseException], ...]: + exception_types: Final = native_exception_types() + return (exception_types[0],) if exception_types is not None else () diff --git a/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py b/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py index 7e6de474b0b..cd6739777dd 100644 --- a/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py +++ b/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py @@ -2162,6 +2162,10 @@ class MCPRequestHandler: # No team restrictions → use key restrictions allowed_tools = cast(list[str], key_tools) + allowed_tools = _as_list( + await MCPRequestHandler._apply_end_user_tool_ceiling(allowed_tools, server_id, user_api_key_auth) + ) + allowed_tools = _as_list( await MCPRequestHandler._apply_user_tool_ceiling( allowed_tools, server_id, user_api_key_auth, keyless_source=keyless_source @@ -3027,6 +3031,38 @@ class MCPRequestHandler: return list(user_tools) return list(set(allowed_tools) & set(user_tools)) + @staticmethod + async def _apply_end_user_tool_ceiling( + allowed_tools: Sequence[str] | None, + server_id: str, + user_api_key_auth: UserAPIKeyAuth | None = None, + ) -> Sequence[str] | None: + """Narrow a key/team tool allowlist by the end user's (customer's) tool entitlement.""" + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + from litellm.proxy.proxy_server import prisma_client + + if user_api_key_auth is None or not user_api_key_auth.end_user_id or prisma_client is None: + return allowed_tools + + object_permissions: Final = await MCPRequestHandler._get_end_user_object_permission( + user_api_key_auth, prisma_client + ) + if object_permissions is None: + return allowed_tools + + end_user_direct_tools: Final = global_mcp_server_manager.expand_tool_permissions( + object_permissions.mcp_tool_permissions + ).get(server_id) + end_user_toolset_tools: Final = await MCPRequestHandler._toolset_tools_for_server(object_permissions, server_id) + end_user_tools: Final = MCPRequestHandler._union_tool_grants(end_user_direct_tools, end_user_toolset_tools) + if end_user_tools is None: + return allowed_tools + if allowed_tools is None: + return list(end_user_tools) + return list(set(allowed_tools) & set(end_user_tools)) + # Sentinel stored in cache when an agent has no object_permission, so we # don't re-query the DB on every MCP request for that agent. _AGENT_NO_PERMISSION_SENTINEL = "__agent_no_mcp_permission__" diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index cb7a18cd107..7a110eff080 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -3234,6 +3234,17 @@ } ], "title": "User Email" + }, + "user_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "User Id" } }, "title": "KeyMetadata", @@ -5134,6 +5145,49 @@ "anthropic_skills" ] } + }, + "/v1/skills/{skill_id}/archive": { + "get": { + "description": "Stored skill upload, repacked so SKILL.md sits at the archive root.", + "operationId": "agent_skills_archive_v1_skills__skill_id__archive_get", + "parameters": [ + { + "in": "path", + "name": "skill_id", + "required": true, + "schema": { + "title": "Skill Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/zip": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Agent Skills Archive", + "tags": [ + "anthropic_skills" + ] + } } } }, diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 85677f4eb3c..ae6c042ab3a 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -1307,6 +1307,11 @@ class UpdateKeyRequest(KeyRequestBase): rotation_interval: str | None = None organization_id: str | None = None + project_id: str | None = Field( + default=None, + description="Omit to retain the project, or send null to detach. Assigning a different project is not supported.", + ) + @model_validator(mode="before") @classmethod def drop_blank_team_id(cls, values: object) -> object: diff --git a/litellm/proxy/client/cli/README.md b/litellm/proxy/client/cli/README.md index 2071576a943..3b0ff9d7add 100644 --- a/litellm/proxy/client/cli/README.md +++ b/litellm/proxy/client/cli/README.md @@ -536,7 +536,30 @@ It writes the same settings `lite up` does, `env.ANTHROPIC_BASE_URL`, `env.ENABL The key in the file is the login's own, so it expires with it (24h by default): run `lite login --config-claude` again after that, which rewrites the key in place. Earlier versions wrote an `apiKeyHelper` that ran `lite auth print-token` instead, so a later login refreshed Claude Code by itself; that meant Claude Code spawning a full `lite` start, keychain check included, on every credential refresh, so the helper is no longer written and a stale one is stripped by the next `--config-claude` or `configure claude`. Like `lite up`, the flag refuses to run while a `lite up` session holds a backup, and tells you to run `lite down` first -#### Configuring Claude Code Once, With a Virtual Key +#### Configuring Claude Code or Codex Once, With a Virtual Key + +Run the setup wizard with your gateway URL and a long-lived virtual key: + +```bash +lite configure --api-key sk-... --gateway-url https://your-proxy.example.com +``` + +Select Claude Code, Codex, or both, then choose a gateway model for each selected agent. The wizard validates the key and reads the models your key can access before changing settings. Start either configured agent normally with `claude` or `codex`; the gateway connection persists across terminals without a wrapper or exported API key + +`--gateway-url` also accepts a deployment path prefix and a trailing `/v1`. `--base-url` is an alias. If omitted, setup uses `lite --base-url`, `LITELLM_PROXY_URL`, or the saved CLI URL; the wizard asks for a URL when none was provided + +For a scripted setup, name the agent and model: + +```bash +lite configure --gateway-url https://your-proxy.example.com codex --api-key sk-... --model my-coding-model +lite unconfigure codex +``` + +Codex setup requires an installed stable Codex version of [0.129.0 or newer](https://github.com/openai/codex/releases/tag/rust-v0.129.0), which prevents repository settings from redirecting requests carrying your saved key. Setup checks `codex --version` before fetching models or changing either selected agent's settings. Undo remains available without Codex installed + +Codex setup updates `~/.codex/config.toml` (or `$CODEX_HOME/config.toml`) with the selected model and a LiteLLM Responses provider. The gateway key lives in that provider's static Authorization header, in a file written atomically with owner-only permissions. Other providers, hooks, MCP servers and comments are preserved. A default profile selection is removed so it cannot override the gateway settings; its contents are preserved, and undo restores the selection. Explicit Codex flags and supported project settings still follow Codex's normal precedence + +The Codex undo receipt is kept in a private `.litellm` directory beside the resolved config file. `lite unconfigure codex` restores only values still holding what configure wrote, preserving later edits. The provider URL and credential are restored together. Symlinks are followed and their targets become owner-only; keep these credential-bearing files out of version control `lite configure claude` wires Claude Code up persistently with a long-lived virtual key, a pinned model and an undo, and `lite unconfigure claude` puts things back: @@ -548,7 +571,7 @@ claude The key comes from `--api-key` (or `lite --api-key` / `LITELLM_PROXY_API_KEY`) and is written into `env.ANTHROPIC_AUTH_TOKEN`; without one the command refuses, since a `lite login` credential expires within a day and keeping it fresh would mean Claude Code running `lite` through `apiKeyHelper` on every credential refresh. The command checks the key against `GET /v1/models`, then patches `~/.claude/settings.json`: `env.ANTHROPIC_BASE_URL`, the credential, and `env.ENABLE_TOOL_SEARCH` and `env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY` when those are missing, so Claude Code's `/model` picker lists the proxy's models (under `claude-router-` for a group whose id contains neither `claude` nor `anthropic`, since Claude Code lists only those) and you pick between them as usual. Claude Code keeps its own default model until you switch, so that id has to exist on the proxy for the first message to go through; `--model` (or the interactive prompt below) sets the model Claude Code starts on instead, as the top-level `model` key and as `env.ANTHROPIC_MODEL`, both of which have to be on `/v1/models` for the key. The second one matters for `claude -c` and `claude --resume`: a resumed session otherwise re-sends the model its transcript recorded, which behind an auto-router with `return_raw_model_name: true` is the tier model that answered, and a key scoped to the router alias gets a 403 for it; `ANTHROPIC_MODEL` outranks the transcript on resume. Nothing forces Claude Code's sub-agent or background tiers onto a proxy model, so those built-in ids need to exist on the proxy too; `lite autoroute up` is the mode that pins every tier to one group. Claude Code treats a name it does not know as an unknown model: it prints a one-line `unrecognized_model` note, assumes a 200k context window (the proxy appends `[1m]` for a group whose configured or known input window reaches 1M) and sends no thinking parameters for it, so name the group like a Claude model id to change that. The other credential slots (`env.ANTHROPIC_API_KEY`, a stale `env.ANTHROPIC_AUTH_TOKEN` or `apiKeyHelper`) are removed so they cannot fight the one written. Every other setting is preserved and the file is written atomically with owner-only permissions; if `settings.json` is a symlink into a dotfiles repository, the key is written through to that target and the command says so, so keep it out of version control -Plain `lite configure`, with no agent named, asks the same things interactively: which agents to wire (Claude Code today) and which of the proxy's models to start on, picked from `/v1/models` with a type-to-filter prompt +Plain `lite configure`, with no agent named, asks which agents to wire and which gateway model each starts on, picked from `/v1/models` with a type-to-filter prompt. All choices and selected config files are checked before the first settings write. If a later filesystem write fails, the output identifies each agent already configured and its undo command What the command changed is recorded in `~/.litellm/claude_configure_state.json` (previous values plus fingerprints of what was written, never a second copy of the key). `lite unconfigure claude` restores each of those keys only if it still holds what `configure` wrote, so anything you changed since is left alone and named in the output; a `settings.json` or `env` object that only existed because of `configure` is removed again. Ownership moves only by a write: running `configure` again (a re-login is one) refreshes the record only for the keys its merge changed, keeps the original snapshot of a key that still holds what it wrote, and snapshots afresh a key you changed in between, so `unconfigure` brings back whatever the repeat displaced and never adopts your edit as its own. A credential (`env.ANTHROPIC_API_KEY`, `env.ANTHROPIC_AUTH_TOKEN`, `apiKeyHelper`) is put back only when the restored file points at the `ANTHROPIC_BASE_URL` it was captured next to; otherwise it stays removed, the output says which server it belonged to, and the receipt is kept so pointing the URL back and running `unconfigure` again finishes the job. It also undoes `lite login --config-claude`, which writes through the same path. Both refuse to run while a `lite up` or `lite autoroute up` session holds a backup, and that check comes before any request diff --git a/litellm/proxy/client/cli/commands/agents.py b/litellm/proxy/client/cli/commands/agents.py index ea1eed65505..93ed0eaba03 100644 --- a/litellm/proxy/client/cli/commands/agents.py +++ b/litellm/proxy/client/cli/commands/agents.py @@ -121,6 +121,18 @@ def build_agent_env( return env +def codex_proxy_provider(base_url: str) -> Mapping[str, str | bool]: + return MappingProxyType( + { + "name": "LiteLLM proxy", + "base_url": base_url.rstrip("/") + "/v1", + "wire_api": "responses", + "supports_websockets": False, + "requires_openai_auth": False, + } + ) + + def _codex_proxy_args(base_url: str) -> list[str]: """Codex `-c` overrides that point it at the proxy. @@ -130,21 +142,19 @@ def _codex_proxy_args(base_url: str) -> list[str]: because the proxy does not speak the Responses WebSocket protocol. The key is read from OPENAI_API_KEY, which build_agent_env already exports. """ - root: Final = base_url.rstrip("/") + "/v1" provider: Final = f"model_providers.{CODEX_PROXY_PROVIDER}" return [ "-c", f'model_provider="{CODEX_PROXY_PROVIDER}"', - "-c", - f'{provider}.name="LiteLLM proxy"', - "-c", - f'{provider}.base_url="{root}"', + *( + argument + for key, value in codex_proxy_provider(base_url).items() + for argument in ("-c", f"{provider}.{key}={json.dumps(value)}") + ), "-c", f'{provider}.env_key="{OPENAI_API_KEY_ENV}"', "-c", - f'{provider}.wire_api="responses"', - "-c", - f"{provider}.supports_websockets=false", + f"{provider}.http_headers={{}}", ] diff --git a/litellm/proxy/client/cli/commands/claude_settings.py b/litellm/proxy/client/cli/commands/claude_settings.py index e6231f3cac9..1473e40070f 100644 --- a/litellm/proxy/client/cli/commands/claude_settings.py +++ b/litellm/proxy/client/cli/commands/claude_settings.py @@ -458,11 +458,17 @@ def read_configure_receipt(state_path: Path) -> ConfigureReceipt | None: return ConfigureReceipt.model_validate_json(state_path.read_bytes()) except (OSError, ValidationError) as e: raise ClaudeSettingsError( - f"{state_path} is not a readable `lite configure claude` receipt ({e}). " + f"{state_path} is not a readable `lite configure claude` receipt. " "Remove it and edit Claude Code's settings by hand if they still point at the proxy." ) from e +def preflight_claude_settings(settings_path: Path) -> None: + refuse_while_owned(settings_path, settings_file_owners(settings_path)) + _env_object(load_json_or_empty(settings_path), settings_path) + read_configure_receipt(configure_state_path(settings_path)) + + def configure_claude_settings( base_url: str, credential: StaticToken, diff --git a/litellm/proxy/client/cli/commands/codex_settings.py b/litellm/proxy/client/cli/commands/codex_settings.py new file mode 100644 index 00000000000..686eaa47ff0 --- /dev/null +++ b/litellm/proxy/client/cli/commands/codex_settings.py @@ -0,0 +1,307 @@ +import hashlib +import json +import re +import subprocess +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from functools import reduce +from pathlib import Path +from types import MappingProxyType +from typing import Final, Literal, TypeAlias + +import tomlkit +from pydantic import BaseModel, ConfigDict, ValidationError +from tomlkit.container import OutOfOrderTableProxy +from tomlkit.exceptions import TOMLKitError +from tomlkit.items import InlineTable, Table +from tomlkit.toml_document import TOMLDocument + +from litellm.litellm_core_utils.private_json import ( + commit_staged_json, + discard_staged_json, + ensure_private_dir, + stage_private_bytes, + stage_private_json, +) + +from .agents import CODEX_PROXY_PROVIDER, codex_proxy_provider + +_PROVIDER_PATH: Final = f"model_providers.{CODEX_PROXY_PROVIDER}" +_OWNED_PATHS: Final = ("model_provider", "model", "profile", _PROVIDER_PATH) +_Table: TypeAlias = TOMLDocument | Table | InlineTable | OutOfOrderTableProxy +_EMPTY: Final[Mapping[str, object]] = MappingProxyType({}) +_MIN_CODEX_VERSION: Final = (0, 129, 0) + + +class CodexSettingsError(Exception): + pass + + +class _Receipt(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + version: Literal[1] = 1 + settings_path: str + file_existed: bool + providers_existed: bool + previous: Mapping[str, str | None] + written: Mapping[str, str] + + +@dataclass(frozen=True, slots=True) +class CodexUnconfigureOutcome: + restored: tuple[str, ...] + kept: tuple[str, ...] + file_removed: bool + + +def codex_configure_state_path(settings_path: Path) -> Path: + target: Final = settings_path.resolve() + digest: Final = hashlib.sha256(str(target).encode()).hexdigest() + return target.parent / ".litellm" / f"codex_configure_{digest}.json" + + +def _read(settings_path: Path) -> TOMLDocument: + try: + document: Final = tomlkit.parse(settings_path.read_bytes()) if settings_path.exists() else tomlkit.document() + except (OSError, UnicodeError, TOMLKitError) as error: + raise CodexSettingsError( + f"Could not read Codex settings at {settings_path}; no settings were changed" + ) from error + providers: Final = _mapping(document).get("model_providers") + parent: Final = _table(providers) + if providers is not None and parent is None: + raise CodexSettingsError("Codex model_providers must be a TOML table; no settings were changed") + entries: Final = _mapping(parent) if parent is not None else _EMPTY + configured: Final = entries.get(CODEX_PROXY_PROVIDER) + if configured is not None and _table(configured) is None: + raise CodexSettingsError("Codex model_providers.litellm must be a TOML table; no settings were changed") + return document + + +def _mapping(value: Mapping[str, object]) -> Mapping[str, object]: + return value + + +def _table(value: object) -> _Table | None: + return value if isinstance(value, (TOMLDocument, Table, InlineTable, OutOfOrderTableProxy)) else None + + +def _snapshot(document: TOMLDocument, path: str) -> str | None: + section, _, key = path.rpartition(".") + parent: Final = _table(_mapping(document).get(section)) if section else document + if parent is None or key not in parent: + return None + values: Final = _mapping(parent) + return tomlkit.dumps(MappingProxyType({"value": values[key]})) + + +def _fingerprint(value: str | None) -> str: + normalized: Final = "missing" if value is None else json.dumps(tomlkit.parse(value), sort_keys=True, default=str) + return hashlib.sha256(normalized.encode()).hexdigest() + + +def _with(document: TOMLDocument, path: str, snapshot: str | None) -> TOMLDocument: + section, _, key = path.rpartition(".") + if section and section not in document and snapshot is not None: + contents: Final = tomlkit.parse(tomlkit.dumps(MappingProxyType({key: tomlkit.parse(snapshot).item("value")}))) + return tomlkit.parse(document.as_string() + "\n" + tomlkit.dumps(MappingProxyType({section: contents}))) + # mutable-ok: TOMLKit editing requires private node mutation to preserve comments and order + updated: Final = tomlkit.parse(document.as_string()) + parent: Final = _table(_mapping(updated).get(section)) if section else updated + if parent is None: + return updated + if snapshot is None: + if key in parent: + del parent[key] + else: + parent[key] = tomlkit.parse(snapshot).item("value") + return updated + + +def _receipt(settings_path: Path) -> _Receipt | None: + path: Final = codex_configure_state_path(settings_path) + if not path.exists(): + return None + try: + receipt: Final = _Receipt.model_validate_json(path.read_bytes()) + if receipt.settings_path != str(settings_path.resolve()) or frozenset(receipt.previous) != frozenset( + receipt.written + ): + raise ValueError("invalid receipt scope") + if not frozenset(receipt.written) <= frozenset(_OWNED_PATHS): + raise ValueError("invalid receipt ownership") + for snapshot in receipt.previous.values(): + if snapshot is not None and tuple(tomlkit.parse(snapshot)) != ("value",): + raise ValueError("invalid receipt snapshot") + except (OSError, UnicodeError, TOMLKitError, ValidationError, ValueError) as error: + raise CodexSettingsError( + f"Could not read the Codex configure receipt at {path}; no settings were changed" + ) from error + return receipt + + +def _codex_version() -> str | None: + try: + result: Final = subprocess.run(("codex", "--version"), capture_output=True, text=True, timeout=5, check=False) + except (OSError, subprocess.SubprocessError, UnicodeError): + return None + return result.stdout if result.returncode == 0 else None + + +def require_safe_codex(*, version: Callable[[], str | None] = _codex_version) -> None: + output: Final = version() + matched: Final = re.fullmatch(r"codex-cli (\d+)\.(\d+)\.(\d+)", output.strip()) if output is not None else None + if matched is not None and tuple(int(part) for part in matched.groups()) >= _MIN_CODEX_VERSION: + return + raise CodexSettingsError( + "Codex 0.129.0 or newer (stable) must be installed before saving a gateway key. " + "Older versions allow repository settings to redirect authenticated requests. " + "Install or update Codex, check `codex --version`, then retry." + ) + + +def preflight_codex_settings(settings_path: Path) -> None: + require_safe_codex() + _read(settings_path) + _receipt(settings_path) + + +def _ours(document: TOMLDocument, path: str, receipt: _Receipt) -> bool: + return receipt.written.get(path) == _fingerprint(_snapshot(document, path)) + + +def _stage_settings(path: Path, document: TOMLDocument) -> str: + try: + return stage_private_bytes(str(path), document.as_string().encode()) + except OSError as error: + raise CodexSettingsError(f"Could not stage Codex settings at {path}; no settings were changed") from error + + +def _commit(path: Path, staged: str | None, commit: Callable[[str, str], None]) -> None: + if staged is None: + path.unlink(missing_ok=True) + else: + commit(staged, str(path)) + + +def configure_codex_settings( + base_url: str, + api_key: str, + model: str, + settings_path: Path, + *, + commit: Callable[[str, str], None] = commit_staged_json, +) -> None: + require_safe_codex() + current: Final = _read(settings_path) + earlier: Final = _receipt(settings_path) + headers: Final = tomlkit.parse(tomlkit.dumps(MappingProxyType({"Authorization": f"Bearer {api_key}"}))) + provider_table: Final = tomlkit.parse( + tomlkit.dumps(MappingProxyType({**codex_proxy_provider(base_url), "http_headers": headers})) + ) + provider: Final = tomlkit.dumps(MappingProxyType({"value": provider_table})) + selections: Final = tomlkit.parse( + tomlkit.dumps(MappingProxyType({"model_provider": CODEX_PROXY_PROVIDER, "model": model})) + ) + merged: Final = _with( + _with( + _with(_with(current, "profile", None), "model", _snapshot(selections, "model")), + "model_provider", + _snapshot(selections, "model_provider"), + ), + _PROVIDER_PATH, + provider, + ) + owned: Final = tuple( + path + for path in _OWNED_PATHS + if _fingerprint(_snapshot(current, path)) != _fingerprint(_snapshot(merged, path)) + or (earlier is not None and _ours(current, path, earlier)) + ) + receipt: Final = _Receipt( + settings_path=str(settings_path.resolve()), + file_existed=settings_path.exists() if earlier is None else earlier.file_existed, + providers_existed="model_providers" in current if earlier is None else earlier.providers_existed, + previous=MappingProxyType( + { + path: earlier.previous[path] + if earlier is not None and _ours(current, path, earlier) + else _snapshot(current, path) + for path in owned + } + ), + written=MappingProxyType({path: _fingerprint(_snapshot(merged, path)) for path in owned}), + ) + target: Final = settings_path.resolve() + state_path: Final = codex_configure_state_path(settings_path) + try: + ensure_private_dir(state_path.parent) + staged_receipt: Final = stage_private_json(str(state_path), receipt.model_dump(mode="json")) + except OSError as error: + raise CodexSettingsError(f"Could not stage the Codex configure receipt at {state_path}") from error + try: + staged_settings: Final = _stage_settings(target, merged) + except CodexSettingsError: + discard_staged_json(staged_receipt) + raise + try: + commit(staged_receipt, str(state_path)) + except OSError as error: + discard_staged_json(staged_receipt) + discard_staged_json(staged_settings) + raise CodexSettingsError( + f"Could not write the Codex configure receipt at {state_path}; no settings were changed" + ) from error + try: + commit(staged_settings, str(target)) + except OSError as error: + discard_staged_json(staged_settings) + try: + _commit( + state_path, + None if earlier is None else stage_private_json(str(state_path), earlier.model_dump(mode="json")), + commit_staged_json, + ) + except OSError as rollback_error: + raise CodexSettingsError( + f"Codex settings were not written and its receipt at {state_path} could not be restored" + ) from rollback_error + raise CodexSettingsError( + f"Could not write Codex settings at {settings_path}; the earlier receipt was restored" + ) from error + + +def unconfigure_codex_settings( + settings_path: Path, *, commit: Callable[[str, str], None] = commit_staged_json +) -> CodexUnconfigureOutcome: + current: Final = _read(settings_path) + receipt: Final = _receipt(settings_path) + if receipt is None: + raise CodexSettingsError("Codex is not configured by `lite configure codex`; nothing to undo") + ours: Final = tuple(path for path in receipt.written if settings_path.exists() and _ours(current, path, receipt)) + restored_owned: Final = reduce(lambda document, path: _with(document, path, receipt.previous[path]), ours, current) + providers: Final = _table(_mapping(restored_owned).get("model_providers")) + restored: Final = ( + _with(restored_owned, "model_providers", None) + if providers is not None and not providers and not receipt.providers_existed + else restored_owned + ) + target: Final = settings_path.resolve() + file_removed: Final = not restored.as_string().strip() and not (receipt.file_existed and target.exists()) + staged: Final = None if file_removed else _stage_settings(target, restored) + state_path: Final = codex_configure_state_path(settings_path) + try: + _commit(target, staged, commit) + state_path.unlink() + except OSError as error: + if staged is not None: + discard_staged_json(staged) + raise CodexSettingsError( + "Could not finish undoing Codex configuration; the receipt was kept for retry" + ) from error + return CodexUnconfigureOutcome( + restored=tuple(path for path in ours if _snapshot(current, path) != _snapshot(restored, path)), + kept=tuple(path for path in receipt.written if path not in ours and _snapshot(current, path) is not None), + file_removed=file_removed, + ) diff --git a/litellm/proxy/client/cli/commands/config.py b/litellm/proxy/client/cli/commands/config.py index 2715a0a9a38..de22251ea8c 100644 --- a/litellm/proxy/client/cli/commands/config.py +++ b/litellm/proxy/client/cli/commands/config.py @@ -62,12 +62,16 @@ def hidden_command_names() -> frozenset[str]: return parse_hidden_commands(get_config_value(HIDDEN_COMMANDS_KEY)) -def _normalize_base_url(value: str) -> str: +def normalize_base_url(value: str) -> str: + if any(ord(char) <= 32 or ord(char) == 127 for char in value): + raise click.UsageError("base_url must not contain whitespace or control characters") parsed: Final = urlparse(value) if parsed.scheme not in ("http", "https") or not parsed.netloc: raise click.UsageError("base_url must be a full http:// or https:// URL including a host") if "?" in value or "#" in value: raise click.UsageError("base_url must not include a query string or fragment") + if parsed.username is not None or parsed.password is not None: + raise click.UsageError("base_url must not contain credentials; pass --api-key separately") return value.rstrip("/") @@ -86,7 +90,7 @@ def _normalize_hidden_commands(value: str) -> str: _NORMALIZERS: Final[Mapping[str, Callable[[str], str]]] = MappingProxyType( { - "base_url": _normalize_base_url, + "base_url": normalize_base_url, HIDDEN_COMMANDS_KEY: _normalize_hidden_commands, } ) diff --git a/litellm/proxy/client/cli/commands/configure.py b/litellm/proxy/client/cli/commands/configure.py index 4acf94e16f9..7988f8aef3c 100644 --- a/litellm/proxy/client/cli/commands/configure.py +++ b/litellm/proxy/client/cli/commands/configure.py @@ -1,4 +1,4 @@ -"""`lite configure claude` and `lite unconfigure claude`: persistent Claude Code wiring, undoable.""" +"""Persistent Claude Code and Codex gateway configuration.""" import os import sys @@ -11,6 +11,7 @@ from typing import Final import click from InquirerPy import inquirer from InquirerPy.base.control import Choice +from pydantic import BaseModel from litellm.proxy.common_utils.model_listing_utils import ( CLAUDE_CODE_CLIENT, @@ -18,6 +19,7 @@ from litellm.proxy.common_utils.model_listing_utils import ( GATEWAY_CLIENT_HEADER, ) +from .agents import codex_config_path from .auth import CliContextObj from .claude_settings import ( STARTING_MODEL_ROLE, @@ -30,15 +32,23 @@ from .claude_settings import ( claude_settings_path, configure_claude_settings, configure_state_path, - refuse_while_owned, + preflight_claude_settings, settings_file_owners, unconfigure_claude_settings, ) +from .codex_settings import ( + CodexSettingsError, + configure_codex_settings, + preflight_codex_settings, + unconfigure_codex_settings, +) +from .config import normalize_base_url from .pi import ListedModel, ListingFailure, PiSyncError, fetch_model_listing _LISTED_MODELS_SHOWN: Final = 20 _CLAUDE_TARGET: Final = "claude" -_TARGETS: Final = ((_CLAUDE_TARGET, "Claude Code (CLI)"),) +_CODEX_TARGET: Final = "codex" +_TARGETS: Final = ((_CLAUDE_TARGET, "Claude Code (CLI)"), (_CODEX_TARGET, "Codex (CLI)")) _KEEP_DEFAULT_MODEL: Final = "Keep Claude Code's own default" _CLAUDE_CODE_VIEW: Final = MappingProxyType( {"anthropic-version": "2023-06-01", GATEWAY_CLIENT_HEADER: CLAUDE_CODE_CLIENT} @@ -60,10 +70,12 @@ def resolve_credential(ctx: click.Context, api_key: str | None) -> StaticToken: explicit: Final = api_key or (None if ctx_obj.get("api_key_from_token_file") else ctx_obj.get("api_key")) if not explicit: raise ClaudeSettingsError( - "`lite configure claude` needs a long-lived virtual key: pass --api-key, `lite --api-key`, or set " + "`lite configure` needs a long-lived virtual key: pass --api-key, `lite --api-key`, or set " "LITELLM_PROXY_API_KEY. Your `lite login` credential expires within a day, so it is not written " - "into Claude Code's settings." + "into agent settings." ) + if not explicit.strip() or any(ord(char) <= 32 or ord(char) == 127 for char in explicit): + raise ClaudeSettingsError("The virtual key must not be blank or contain whitespace or control characters.") return StaticToken(explicit) @@ -76,33 +88,45 @@ class _Listing: return tuple(model.id for model in self.models) -def _start(ctx: click.Context, api_key: str | None) -> tuple[StaticToken, _Listing]: - """Every configure path begins the same way: the local ownership check first, so a `lite up` - session is refused before any request, then the credential, then the listing.""" - settings_path: Final = claude_settings_path(os.environ) +def _preflight(target: str) -> None: + try: + if target == _CLAUDE_TARGET: + preflight_claude_settings(claude_settings_path(os.environ)) + else: + preflight_codex_settings(codex_config_path(os.environ)) + except (ClaudeSettingsError, CodexSettingsError) as e: + raise click.ClickException(str(e)) from e + + +def _start(ctx: click.Context, api_key: str | None, target: str = _CLAUDE_TARGET) -> tuple[StaticToken, _Listing]: + _preflight(target) try: - refuse_while_owned(settings_path, settings_file_owners(settings_path)) credential: Final = resolve_credential(ctx, api_key) except ClaudeSettingsError as e: raise click.ClickException(str(e)) - return credential, _listed_models(ctx.obj["base_url"], credential.token) + return credential, _listed_models(ctx.obj["base_url"], credential.token, target) -def _listing_error(base_url: str, error: PiSyncError) -> str: +def _listing_error(base_url: str, error: PiSyncError, target: str) -> str: """The hint that fits how the listing failed: only an unreachable proxy gets the "is it running" question.""" if error.kind is ListingFailure.REJECTED: return f"LiteLLM rejected your key (HTTP {error.status}). Pass a valid --api-key." if error.kind is ListingFailure.UNREACHABLE: - return f"{error.message} Is the proxy at {base_url} running, and is --base-url (or LITELLM_PROXY_URL) correct?" + return ( + f"Could not connect. Is the proxy at {base_url} running, and is --base-url (or LITELLM_PROXY_URL) correct?" + ) if error.kind is ListingFailure.EMPTY: - return f"{error.message} Claude Code would have nothing to run; give the key access to at least one model." - return f"{error.message} The proxy at {base_url} answered, so check that it is a LiteLLM proxy and is healthy." + name: Final = "Claude Code" if target == _CLAUDE_TARGET else "Codex" + return f"{error.message} {name} would have nothing to run; give the key access to at least one model." + return f"The proxy at {base_url} answered, so check that it is a LiteLLM proxy and is healthy." -def _listed_models(base_url: str, key: str) -> _Listing: - listed: Final = fetch_model_listing(base_url, key, headers=_CLAUDE_CODE_VIEW) +def _listed_models(base_url: str, key: str, target: str = _CLAUDE_TARGET) -> _Listing: + listed: Final = fetch_model_listing( + base_url, key, headers=_CLAUDE_CODE_VIEW if target == _CLAUDE_TARGET else MappingProxyType({}) + ) if isinstance(listed, PiSyncError): - raise click.ClickException(_listing_error(base_url, listed)) + raise click.ClickException(_listing_error(base_url, listed, target)) return _Listing(listed) @@ -115,17 +139,19 @@ def _model_choice(model: str | None) -> ModelChoice: return StartOn(model) if model is not None else UnpinModel() +def _validated_model(model: str | None, listing: _Listing, base_url: str) -> str | None: + starting: Final = _starting_model(model, listing) if model is not None else None + if model is not None and starting is None: + shown: Final = ", ".join(listing.ids[:_LISTED_MODELS_SHOWN]) + raise click.ClickException(f"{model!r} is not served by {base_url} for this key. /v1/models lists: {shown}.") + return starting + + def _apply_claude(ctx: click.Context, credential: StaticToken, listing: _Listing, model: str | None) -> None: ctx_obj: Final[CliContextObj] = ctx.obj base_url: Final = ctx_obj["base_url"] listed: Final = listing.ids - starting: Final = _starting_model(model, listing) if model is not None else None - if model is not None and starting is None: - shown: Final = ", ".join(listed[:_LISTED_MODELS_SHOWN]) - more: Final = f", and {len(listed) - _LISTED_MODELS_SHOWN} more" if len(listed) > _LISTED_MODELS_SHOWN else "" - raise click.ClickException( - f"{model!r} is not served by {base_url} for this key. /v1/models lists: {shown}{more}." - ) + starting: Final = _validated_model(model, listing, base_url) settings_path: Final = claude_settings_path(os.environ) try: configure_claude_settings( @@ -178,40 +204,131 @@ def _pick_model(listed: Sequence[str]) -> str | None: picked: Final = inquirer.fuzzy( message="Model Claude Code starts on (type to filter; /model switches any time):", choices=[_KEEP_DEFAULT_MODEL, *listed], + default=listed[0] if listed else _KEEP_DEFAULT_MODEL, ).execute() return None if picked == _KEEP_DEFAULT_MODEL else str(picked) +def _pick_codex_model(listed: Sequence[str]) -> str: + choices: Final = list(listed) # mutable-ok: InquirerPy's choices parameter requires a list + return str(inquirer.fuzzy(message="Model Codex starts on (type to filter):", choices=choices).execute()) + + +def _apply_codex(ctx: click.Context, credential: StaticToken, listing: _Listing, model: str) -> None: + base_url: Final[str] = ctx.obj["base_url"] + _validated_model(model, listing, base_url) + settings_path: Final = codex_config_path(os.environ) + try: + configure_codex_settings(base_url, credential.token, model, settings_path) + except CodexSettingsError as e: + raise click.ClickException(str(e)) from e + click.echo(f"Configured Codex: {settings_path} now routes through {base_url}.") + click.echo(f"Starting model: {model}. Credential: your virtual key, stored in the private provider settings.") + click.echo("Start `codex` from any terminal. Undo with `lite unconfigure codex`.") + if settings_path.is_symlink(): + click.echo(f"Note: your key now lives in {settings_path.resolve()}; keep it out of version control.", err=True) + + +@dataclass(frozen=True, slots=True) +class _Setup: + target: str + listing: _Listing + model: str | None + + +def _choose_setup( + ctx: click.Context, + target: str, + credential: StaticToken, + pick_model: Callable[[Sequence[str]], str | None], + pick_codex_model: Callable[[Sequence[str]], str], +) -> _Setup: + base_url: Final[str] = ctx.obj["base_url"] + listing: Final = _listed_models(base_url, credential.token, target) + model: Final = ( + pick_model(tuple(item.source_model or item.id for item in listing.models)) + if target == _CLAUDE_TARGET + else pick_codex_model(listing.ids) + ) + _validated_model(model, listing, base_url) + return _Setup(target, listing, model) + + def interactive_configure( ctx: click.Context, pick_targets: Callable[[], tuple[str, ...]] = _pick_targets, pick_model: Callable[[Sequence[str]], str | None] = _pick_model, + pick_codex_model: Callable[[Sequence[str]], str] = _pick_codex_model, ) -> None: """`lite configure` with no agent named: ask which agents to wire and which model to pin.""" targets: Final = pick_targets() - if _CLAUDE_TARGET not in targets: + if not targets: return - credential, listing = _start(ctx, None) - _apply_claude( - ctx, credential, listing, pick_model(tuple(model.source_model or model.id for model in listing.models)) + for target in targets: + _preflight(target) + try: + credential: Final = resolve_credential(ctx, None) + except ClaudeSettingsError as e: + raise click.ClickException(str(e)) from e + setups: Final = tuple(_choose_setup(ctx, target, credential, pick_model, pick_codex_model) for target in targets) + for setup in setups: + if setup.target == _CLAUDE_TARGET: + _apply_claude(ctx, credential, setup.listing, setup.model) + elif setup.model is not None: + _apply_codex(ctx, credential, setup.listing, setup.model) + + +class _ConnectionOptions(BaseModel): + api_key: str | None = None + gateway_url: str | None = None + + +def _connection_context(ctx: click.Context, api_key: str | None, gateway_url: str | None) -> click.Context: + ctx_obj: Final[CliContextObj] = ctx.obj + group: Final = ( + _ConnectionOptions.model_validate(ctx.parent.params) + if ctx.parent is not None and ctx.parent.command.name == "configure" + else _ConnectionOptions() ) + key: Final = api_key if api_key is not None else group.api_key + url: Final = gateway_url if gateway_url is not None else group.gateway_url + normalized: Final = normalize_base_url(url if url is not None else ctx_obj["base_url"]) + connection: Final[CliContextObj] = { + **ctx_obj, + "base_url": normalized.removesuffix("/v1"), + "base_url_explicit": url is not None or ctx_obj.get("base_url_explicit", False), + "api_key": key if key is not None else ctx_obj.get("api_key"), + "api_key_from_token_file": False if key is not None else ctx_obj.get("api_key_from_token_file", False), + } + return click.Context(ctx.command, parent=ctx.parent, obj=connection) @click.group(name="configure", invoke_without_command=True) +@click.option("--api-key", default=None, help="Long-lived LiteLLM virtual key to store in the selected agents.") +@click.option( + "--gateway-url", "--base-url", default=None, help="Gateway URL; defaults to `lite --base-url` / LITELLM_PROXY_URL." +) @click.pass_context -def configure_group(ctx: click.Context) -> None: +def configure_group(ctx: click.Context, api_key: str | None, gateway_url: str | None) -> None: """Persistently route a coding agent through your LiteLLM proxy. With no agent named, asks which agents to wire and which proxy model to pin. """ if ctx.invoked_subcommand is not None: return + connection: Final = _connection_context(ctx, api_key, gateway_url) if not sys.stdin.isatty(): raise click.ClickException( "`lite configure` asks questions, so it needs a terminal. Non-interactively, run " - "`lite configure claude --api-key --model `." + "`lite configure claude --api-key --model ` or " + "`lite configure codex --api-key --model `." ) - interactive_configure(ctx) + prompted: Final = ( + connection + if connection.obj.get("base_url_explicit") + else _connection_context(connection, None, click.prompt("Gateway URL", default=connection.obj["base_url"])) + ) + interactive_configure(prompted) @click.group(name="unconfigure") @@ -228,8 +345,9 @@ def unconfigure_group() -> None: "LITELLM_PROXY_API_KEY value; required, since a `lite login` credential expires within a day.", ) @click.option("--model", default=None, help=_MODEL_OPTION_HELP) +@click.option("--gateway-url", "--base-url", default=None, help="Gateway URL, including any deployment path prefix.") @click.pass_context -def configure_claude(ctx: click.Context, api_key: str | None, model: str | None) -> None: +def configure_claude(ctx: click.Context, api_key: str | None, model: str | None, gateway_url: str | None) -> None: """Route every Claude Code session through your LiteLLM proxy until `lite unconfigure claude`. Patches ~/.claude/settings.json in place: the proxy URL, your virtual key as a static token, @@ -238,8 +356,39 @@ def configure_claude(ctx: click.Context, api_key: str | None, model: str | None) setting is kept, and what changed is recorded so `lite unconfigure claude` can put it back. Assumes the proxy is already running. """ - credential, listing = _start(ctx, api_key) - _apply_claude(ctx, credential, listing, model) + connection: Final = _connection_context(ctx, api_key, gateway_url) + credential, listing = _start(connection, api_key) + _apply_claude(connection, credential, listing, model) + + +@configure_group.command(name="codex") +@click.option("--api-key", default=None, help="Long-lived LiteLLM virtual key to store in Codex's user config.") +@click.option("--gateway-url", "--base-url", default=None, help="Gateway URL, including any deployment path prefix.") +@click.option("--model", required=True, help="Gateway model Codex starts on, as listed by /v1/models for your key.") +@click.pass_context +def configure_codex(ctx: click.Context, api_key: str | None, gateway_url: str | None, model: str) -> None: + """Route plain `codex` through the gateway until `lite unconfigure codex`.""" + connection: Final = _connection_context(ctx, api_key, gateway_url) + credential, listing = _start(connection, api_key, _CODEX_TARGET) + _apply_codex(connection, credential, listing, model) + + +@unconfigure_group.command(name="codex") +def unconfigure_codex() -> None: + """Restore only Codex settings still holding what configure wrote.""" + settings_path: Final = codex_config_path(os.environ) + try: + outcome: Final = unconfigure_codex_settings(settings_path) + except CodexSettingsError as e: + raise click.ClickException(str(e)) from e + if outcome.file_removed: + click.echo(f"Removed {settings_path}; it held only settings created by `lite configure codex`.") + elif outcome.restored: + click.echo(f"Restored in {settings_path}: {', '.join(outcome.restored)}.") + else: + click.echo(f"Nothing in {settings_path} was still ours to restore.") + if outcome.kept: + click.echo(f"Left as you changed them since: {', '.join(outcome.kept)}.") @unconfigure_group.command(name="claude") diff --git a/litellm/proxy/client/cli/main.py b/litellm/proxy/client/cli/main.py index b0e81a222c0..05fb877d0f1 100644 --- a/litellm/proxy/client/cli/main.py +++ b/litellm/proxy/client/cli/main.py @@ -95,7 +95,7 @@ def cli(ctx: click.Context, show_version: bool, base_url: str | None, api_key: s # If no API key provided via flag or environment variable, try to load from saved token. # Pass base_url so we only use the stored key when it was issued for this server. - api_key_from_token_file: Final = api_key is None + api_key_from_token_file: Final = api_key is None and ctx.invoked_subcommand not in ("configure", "unconfigure") resolved_api_key: Final = ( get_stored_api_key(expected_base_url=base_url, vault=context_secret_vault(ctx)) if api_key_from_token_file diff --git a/litellm/proxy/common_request_processing.py b/litellm/proxy/common_request_processing.py index 9f58aaf24f1..e6ed60ba177 100644 --- a/litellm/proxy/common_request_processing.py +++ b/litellm/proxy/common_request_processing.py @@ -3,7 +3,7 @@ import contextlib import json import logging import math -from collections.abc import AsyncGenerator, Awaitable, Callable, Coroutine, Mapping, Sequence +from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping, Sequence from datetime import datetime from functools import lru_cache from types import MappingProxyType @@ -3181,6 +3181,11 @@ class ProxyBaseLLMRequestProcessing: Extracted as a static method so tests can exercise the production gating logic directly rather than reimplementing the finally block. """ + if getattr(logging_obj, "call_type", None) in ("ocr", "aocr"): + pending: Final = getattr(logging_obj, "_native_pending_logging", None) + if pending is not None: + logging_obj._native_pending_logging = None # rebind-ok: consume the native OCR release signal once + pending.release(not exception_raised) _enqueue_fn: Final = getattr(logging_obj, "_enqueue_deferred_logging", None) if _enqueue_fn is None: return @@ -3205,20 +3210,24 @@ class ProxyBaseLLMRequestProcessing: end-of-stream blocks complete, so the spend log sees guardrail_information. - Three closure shapes, matching who owns logging for the stream: + Two closure shapes, matching who owns logging for the stream: - CustomStreamWrapper (chat completions) stores (assembled_response, cache_hit); the closure also runs non-apply_guardrail post-call hooks via _run_deferred_stream_guardrails. - - Bridged /v1/responses (LiteLLMCompletionStreamingIterator) shares - its inner CustomStreamWrapper's logging_obj, so it stores the same - (assembled_response, cache_hit) shape; the closure only dispatches - success logging, matching the route's pre-existing hook surface. - - Native anthropic_messages/aresponses iterators store a single - ready-made logging coroutine to enqueue. + - Every other anthropic_messages/aresponses stream gets a closure + that dispatches on the stored args shape, because the arming site + cannot tell the producers apart: native iterators store a single + ready-made logging coroutine to enqueue, while bridged streams + (LiteLLMCompletionStreamingIterator, and the plain SSE generator + AnthropicStreamWrapper returns for bridged /v1/messages) share + their inner CustomStreamWrapper's logging_obj and so store + (assembled_response, cache_hit); for those the closure only + dispatches success logging, matching the route's pre-existing + hook surface. - Raw async generators from passthrough routes bypass all three and - would orphan the closure, so they are not armed here. + Raw async generators from passthrough routes bypass both and would + orphan the closure, so they are not armed here. The router wraps iterators that cannot carry _hidden_params in HiddenParamsAsyncIteratorWrapper, so class sniffing runs on the @@ -3252,31 +3261,27 @@ class ProxyBaseLLMRequestProcessing: if route_type not in ("anthropic_messages", "aresponses") or not self._is_streaming_response(response): return - from litellm.responses.litellm_completion_transformation.streaming_iterator import ( - LiteLLMCompletionStreamingIterator, - ) - - if isinstance(unwrapped, LiteLLMCompletionStreamingIterator): - _captured_bridge_logging_obj: Final = logging_obj - - async def _on_deferred_bridged_stream_complete(assembled_response: object, cache_hit: object) -> None: - await _as_success_dispatcher(_captured_bridge_logging_obj).dispatch_success_handlers( - assembled_response, - cache_hit=cache_hit, - start_time=None, - end_time=None, - prefer_async_handlers=True, - ) - - logging_obj._on_deferred_stream_complete = _on_deferred_bridged_stream_complete - return - from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER - async def _on_deferred_native_stream_complete( - logging_coroutine: Coroutine[object, object, object], - ) -> None: - GLOBAL_LOGGING_WORKER.ensure_initialized_and_enqueue(async_coroutine=logging_coroutine) + _captured_native_logging_obj: Final = logging_obj + + async def _on_deferred_native_stream_complete(*args: object) -> None: + match args: + case (logging_coroutine,) if asyncio.iscoroutine(logging_coroutine): + GLOBAL_LOGGING_WORKER.ensure_initialized_and_enqueue(async_coroutine=logging_coroutine) + case (assembled_response, cache_hit): + await _as_success_dispatcher(_captured_native_logging_obj).dispatch_success_handlers( + assembled_response, + cache_hit=cache_hit, + start_time=None, + end_time=None, + prefer_async_handlers=True, + ) + case _: + verbose_proxy_logger.error( + "Deferred stream logging dropped: unexpected stored args shape %s", + tuple(type(arg).__name__ for arg in args), + ) logging_obj._on_deferred_stream_complete = _on_deferred_native_stream_complete diff --git a/litellm/proxy/common_utils/config_includes.py b/litellm/proxy/common_utils/config_includes.py new file mode 100644 index 00000000000..c1bb5ae952f --- /dev/null +++ b/litellm/proxy/common_utils/config_includes.py @@ -0,0 +1,132 @@ +import os +from collections.abc import Awaitable, Mapping +from types import MappingProxyType +from typing import Final, Protocol + +from litellm._logging import verbose_proxy_logger + +INCLUDE_KEY: Final = "include" + + +def resolve_include_file_path(include_file: str, declared_in: str, root_config_path: str) -> str: + """ + Resolve one `include` entry to the file it names, next to the config that declares it. + + A config written before nested entries resolved this way can name a file sitting next to the root + config instead, so that file is still read, with a warning naming where it was found. When both + files exist the one next to the declaring config wins and the other is named in a warning. + """ + declared_relative: Final = os.path.abspath(os.path.join(os.path.dirname(declared_in), include_file)) + root_relative: Final = os.path.abspath(os.path.join(os.path.dirname(root_config_path), include_file)) + if root_relative == declared_relative or not os.path.exists(root_relative): + return declared_relative + + if not os.path.exists(declared_relative): + verbose_proxy_logger.warning( + "Config include '%s' declared in %s was not found next to it, so %s was read instead. " + "Move the included file next to the config that declares it.", + include_file, + declared_in, + root_relative, + ) + return root_relative + + verbose_proxy_logger.warning( + "Config include '%s' declared in %s matches two files. %s sits next to that config and was read, " + "so %s was skipped. Rename one of the two to say which one you meant.", + include_file, + declared_in, + declared_relative, + root_relative, + ) + return declared_relative + + +class IncludeResolver(Protocol): + def __call__(self, include_entry: str, declared_in: str, /) -> str: ... + + +class ConfigReader(Protocol): + def __call__(self, location: str, /) -> Awaitable[Mapping[str, object]]: ... + + +def _merged_value(base_value: object, included_value: object) -> object: + if isinstance(included_value, list) and isinstance(base_value, list): + return [*base_value, *included_value] # mutable-ok: a merged config value stays the plain list the proxy loads + return included_value + + +def _merged_entry(base: Mapping[str, object], included: Mapping[str, object], key: str) -> object: + if key not in included: + return base[key] + return _merged_value(base.get(key), included[key]) + + +def _merged(base: Mapping[str, object], included: Mapping[str, object]) -> Mapping[str, object]: + return MappingProxyType({key: _merged_entry(base, included, key) for key in (*base, *included)}) + + +def _without_include(config: Mapping[str, object]) -> Mapping[str, object]: + return MappingProxyType({key: value for key, value in config.items() if key != INCLUDE_KEY}) + + +def include_entries(config: Mapping[str, object]) -> tuple[str, ...]: + if INCLUDE_KEY not in config: + return () + + entries: Final = config[INCLUDE_KEY] + if not isinstance(entries, list): + raise ValueError("'include' must be a list of file paths") + + paths: Final = tuple(entry for entry in entries if isinstance(entry, str)) + if len(paths) != len(entries): + raise ValueError("'include' must be a list of file paths") + + return paths + + +def _pending_from(config: Mapping[str, object], location: str) -> tuple[tuple[str, str], ...]: + return tuple((entry, location) for entry in include_entries(config)) + + +async def _resolve( + config: Mapping[str, object], + pending: tuple[tuple[str, str], ...], + loaded: frozenset[str], + resolve: IncludeResolver, + read: ConfigReader, +) -> Mapping[str, object]: + if not pending: + return _without_include(config) + + entry, declared_in = pending[0] + location: Final = resolve(entry, declared_in) + if location in loaded: + return await _resolve(config, pending[1:], loaded, resolve, read) + + included: Final = await read(location) + return await _resolve( + _merged(config, _without_include(included)), + (*pending[1:], *_pending_from(included, location)), + loaded | frozenset((location,)), + resolve, + read, + ) + + +async def resolve_includes( + config: Mapping[str, object], + location: str, + resolve: IncludeResolver, + read: ConfigReader, +) -> dict[str, object]: + """ + Merge every config named by the `include` directive into the config that declares it. + + List values are extended and every other value is overridden, `resolve` turns each entry into the + location it names relative to the config that declares it, a config already pulled in is neither + read nor merged a second time, and `read` decides where a location is read from, so the same merge + applies to configs on disk and to configs hosted in a bucket. + """ + merged: Final = await _resolve(config, _pending_from(config, location), frozenset((location,)), resolve, read) + return dict(merged) # mutable-ok: the proxy mutates the config it loads diff --git a/litellm/proxy/common_utils/load_config_utils.py b/litellm/proxy/common_utils/load_config_utils.py index 62649ad6ca1..4a082eb307b 100644 --- a/litellm/proxy/common_utils/load_config_utils.py +++ b/litellm/proxy/common_utils/load_config_utils.py @@ -1,12 +1,47 @@ +import asyncio import os -from typing import Final +import posixpath +from collections.abc import Awaitable, Mapping +from types import MappingProxyType +from typing import TYPE_CHECKING, Final, Protocol import yaml +from pydantic import TypeAdapter, ValidationError from litellm._logging import verbose_proxy_logger +from litellm.proxy.common_utils.config_includes import resolve_includes + +if TYPE_CHECKING: + from litellm.integrations.gcs_bucket.gcs_bucket_base import GCSBucketBase + +_BUCKET_CONFIG_ADAPTER: Final = TypeAdapter(dict[str, object]) -def get_file_contents_from_s3(bucket_name, object_key): +class BucketObjectFetcher(Protocol): + def __call__(self, object_key: str, /) -> Awaitable[Mapping[str, object] | None]: ... + + +class BucketObjectReader(Protocol): + def __call__(self, object_key: str, /) -> Awaitable[object | None]: ... + + +class SyncBucketObjectReader(Protocol): + def __call__(self, object_key: str, /) -> object | None: ... + + +def _parsed_config(object_key: str, file_contents: str) -> object | None: + try: + parsed: Final = yaml.safe_load(file_contents) + except yaml.YAMLError as e: + verbose_proxy_logger.error("Config object %s is not valid YAML: %s", object_key, e) + return None + return MappingProxyType({}) if parsed is None else parsed + + +def s3_object_reader(bucket_name: str) -> SyncBucketObjectReader: + """ + Build one reader for a whole config, so an `include` tree costs one S3 client rather than one per object. + """ try: # v0 rely on boto3 for authentication - allowing boto3 to handle IAM credentials etc import boto3 @@ -21,46 +56,147 @@ def get_file_contents_from_s3(bucket_name, object_key): aws_secret_access_key=credentials.secret_key, aws_session_token=credentials.token, # Optional, if using temporary credentials ) - verbose_proxy_logger.debug("Retrieving %s from S3 bucket: %s", object_key, bucket_name) - response: Final = s3_client.get_object(Bucket=bucket_name, Key=object_key) - verbose_proxy_logger.debug("Response: %s", response) - - # Read the file contents and directly parse YAML - file_contents: Final = response["Body"].read().decode("utf-8") - verbose_proxy_logger.debug("File contents retrieved from S3") - - # Parse YAML directly from string - config: Final = yaml.safe_load(file_contents) - return config - except ImportError as e: # this is most likely if a user is not using the litellm docker container verbose_proxy_logger.error("ImportError: %s", e) + return lambda object_key: None except Exception as e: - verbose_proxy_logger.error("Error retrieving file contents: %s", e) + verbose_proxy_logger.error("Error creating the S3 client for bucket %s: %s", bucket_name, e) + return lambda object_key: None + + def read(object_key: str) -> object | None: + try: + verbose_proxy_logger.debug("Retrieving %s from S3 bucket: %s", object_key, bucket_name) + response: Final = s3_client.get_object(Bucket=bucket_name, Key=object_key) + file_contents: Final = response["Body"].read().decode("utf-8") + except Exception as e: # noqa: BLE001 # any boto3 error must read as a missing object + verbose_proxy_logger.error("Error retrieving %s from S3 bucket %s: %s", object_key, bucket_name, e) + return None + + return _parsed_config(object_key, file_contents) + + return read + + +def get_file_contents_from_s3(bucket_name: str, object_key: str) -> object | None: + return s3_object_reader(bucket_name)(object_key) + + +def gcs_config_bucket(bucket_name: str) -> "GCSBucketBase | None": + """ + Build a plain GCS client for reading config objects. + + Reading a config out of a bucket is not GCS logging, so it neither needs the enterprise license + that gate covers nor the batching task the logger starts and never stops. + """ + try: + from litellm.integrations.gcs_bucket.gcs_bucket_base import GCSBucketBase + + return GCSBucketBase(bucket_name=bucket_name) + except Exception as e: # noqa: BLE001 # an unbuildable client must read as an unreadable bucket + verbose_proxy_logger.error("Error creating the GCS client for bucket %s: %s", bucket_name, e) return None -async def get_config_file_contents_from_gcs(bucket_name, object_key): +async def get_config_file_contents_from_gcs( + bucket_name: str, + object_key: str, + gcs_bucket: "GCSBucketBase | None" = None, +) -> object | None: try: - from litellm.integrations.gcs_bucket.gcs_bucket import GCSBucketLogger - - gcs_bucket: Final = GCSBucketLogger( - bucket_name=bucket_name, - ) - file_contents = await gcs_bucket.download_gcs_object(object_key) + bucket: Final = gcs_config_bucket(bucket_name) if gcs_bucket is None else gcs_bucket + if bucket is None: + return None + file_contents: Final = await bucket.download_gcs_object(object_key) if file_contents is None: raise Exception(f"File contents are None for {object_key}") - # file_contentis is a bytes object, so we need to convert it to yaml - file_contents = file_contents.decode("utf-8") - # convert to yaml - config: Final = yaml.safe_load(file_contents) - return config + decoded: Final = file_contents.decode("utf-8") except Exception as e: - verbose_proxy_logger.error("Error retrieving file contents: %s", e) + verbose_proxy_logger.error("Error retrieving %s from GCS bucket %s: %s", object_key, bucket_name, e) return None + return _parsed_config(object_key, decoded) + + +def resolve_include_object_key(config_object_key: str, include_entry: str) -> str: + """ + Resolve one `include` entry to the object key it names, relative to the config object's prefix. + + A leading "/" means the bucket root, mirroring how an absolute path on disk ignores the + directory the including config sits in. + """ + if include_entry.startswith("/"): + return posixpath.normpath(include_entry).lstrip("/") + return posixpath.normpath(posixpath.join(posixpath.dirname(config_object_key), include_entry)) + + +async def resolve_bucket_includes( + *, + config: Mapping[str, object], + object_key: str, + fetch: BucketObjectFetcher, +) -> dict[str, object]: + async def read(include_key: str) -> Mapping[str, object]: + included: Final = await fetch(include_key) + if included is None: + raise FileNotFoundError( + f"Included config could not be read from bucket: {include_key}. " + "The underlying bucket error is logged above." + ) + return included + + def resolve(include_entry: str, declared_in: str) -> str: + return resolve_include_object_key(declared_in, include_entry) + + return await resolve_includes(config=config, location=object_key, resolve=resolve, read=read) + + +async def bucket_object_reader(bucket_type: str | None, bucket_name: str) -> BucketObjectReader: + """ + Build one reader for a whole config, so an `include` tree costs one bucket client rather than one per object. + """ + if bucket_type != "gcs": + read_object: Final = await asyncio.to_thread(s3_object_reader, bucket_name) + + async def read_from_s3(object_key: str) -> object | None: + return await asyncio.to_thread(read_object, object_key) + + return read_from_s3 + + gcs_bucket: Final = gcs_config_bucket(bucket_name) + + async def read_from_gcs(object_key: str) -> object | None: + if gcs_bucket is None: + return None + return await get_config_file_contents_from_gcs(bucket_name, object_key, gcs_bucket) + + return read_from_gcs + + +async def get_config_from_bucket( + *, + bucket_type: str | None, + bucket_name: str, + object_key: str, +) -> dict[str, object] | None: + read: Final = await bucket_object_reader(bucket_type, bucket_name) + + async def fetch(key: str) -> Mapping[str, object] | None: + raw: Final = await read(key) + if raw is None: + return None + try: + return _BUCKET_CONFIG_ADAPTER.validate_python(raw) + except ValidationError as e: + raise ValueError(f"Config object in bucket is not a YAML mapping: {key}") from e + + config: Final = await fetch(object_key) + if not config: + return None + + return await resolve_bucket_includes(config=config, object_key=object_key, fetch=fetch) + def download_python_file_from_s3( bucket_name: str, @@ -136,11 +272,9 @@ async def download_python_file_from_gcs( bool: True if successful, False otherwise """ try: - from litellm.integrations.gcs_bucket.gcs_bucket import GCSBucketLogger + from litellm.integrations.gcs_bucket.gcs_bucket_base import GCSBucketBase - gcs_bucket: Final = GCSBucketLogger( - bucket_name=bucket_name, - ) + gcs_bucket: Final = GCSBucketBase(bucket_name=bucket_name) file_contents = await gcs_bucket.download_gcs_object(object_key) if file_contents is None: raise Exception(f"File contents are None for {object_key}") diff --git a/litellm/proxy/discovery_endpoints/__init__.py b/litellm/proxy/discovery_endpoints/__init__.py index a6401c2f1b4..52602f30b77 100644 --- a/litellm/proxy/discovery_endpoints/__init__.py +++ b/litellm/proxy/discovery_endpoints/__init__.py @@ -1,3 +1,4 @@ +from .agent_skills_endpoints import router as agent_skills_discovery_router from .ui_discovery_endpoints import router as ui_discovery_endpoints_router -__all__ = ["ui_discovery_endpoints_router"] +__all__ = ["agent_skills_discovery_router", "ui_discovery_endpoints_router"] diff --git a/litellm/proxy/discovery_endpoints/agent_skills_archive.py b/litellm/proxy/discovery_endpoints/agent_skills_archive.py new file mode 100644 index 00000000000..1f2fca3992e --- /dev/null +++ b/litellm/proxy/discovery_endpoints/agent_skills_archive.py @@ -0,0 +1,130 @@ +"""Repack a stored skill upload into the archive shape Agent Skills clients install from. + +Uploads follow the Anthropic Skills API layout, where every file sits under a single +top-level folder. Discovery clients read ``SKILL.md`` from the archive root, so that +folder is stripped and the zip is rebuilt with fixed entry timestamps, which keeps the +SHA-256 digest published in the index reproducible for identical uploads. +""" + +import hashlib +import io +import re +import zipfile +from collections.abc import Mapping +from dataclasses import dataclass +from types import MappingProxyType +from typing import Final + +import yaml + +MAX_ARCHIVE_UNPACKED_BYTES: Final = 50 * 1024 * 1024 +MAX_ARCHIVE_ENTRIES: Final = 1000 +SKILL_MANIFEST_FILENAME: Final = "SKILL.md" + +_ZIP_ENTRY_TIMESTAMP: Final = (1980, 1, 1, 0, 0, 0) +_ZIP_ENTRY_PERMISSIONS: Final = 0o644 << 16 +_FRONTMATTER_PATTERN: Final = re.compile(r"^---\s*\n(.*?)\n---\s*(?:\n|$)", re.DOTALL) +_WINDOWS_DRIVE_PATTERN: Final = re.compile(r"^[A-Za-z]:") +_EMPTY_FRONTMATTER: Final[Mapping[str, object]] = MappingProxyType({}) + + +@dataclass(frozen=True, slots=True) +class SkillArchive: + content: bytes + digest: str + declared_name: str | None + declared_description: str | None + + +def build_skill_archive(stored_content: bytes) -> SkillArchive | None: + """Return the installable archive for an upload, or None when it holds no root SKILL.md.""" + try: + with zipfile.ZipFile(io.BytesIO(stored_content)) as uploaded: + members: Final = _flattened_members(uploaded) + except (zipfile.BadZipFile, OSError, RuntimeError): + return None + + if members is None: + return None + + frontmatter: Final = _manifest_frontmatter(next(data for name, data in members if name == SKILL_MANIFEST_FILENAME)) + content: Final = _repack(members) + return SkillArchive( + content=content, + digest=f"sha256:{hashlib.sha256(content).hexdigest()}", + declared_name=_frontmatter_text(frontmatter, "name"), + declared_description=_frontmatter_text(frontmatter, "description"), + ) + + +def _flattened_members(uploaded: zipfile.ZipFile) -> tuple[tuple[str, bytes], ...] | None: + infos: Final = tuple(info for info in uploaded.infolist() if not info.is_dir()) + if not infos or len(infos) > MAX_ARCHIVE_ENTRIES: + return None + if sum(info.file_size for info in infos) > MAX_ARCHIVE_UNPACKED_BYTES: + return None + + normalized: Final = tuple((info, _normalized_path(info.filename)) for info in infos) + if any(path is None for _, path in normalized): + return None + + prefix: Final = _common_root_prefix(tuple(path for _, path in normalized if path is not None)) + flattened: Final = tuple((info, path[len(prefix) :]) for info, path in normalized if path is not None) + names: Final = frozenset(name for _, name in flattened) + if SKILL_MANIFEST_FILENAME not in names or len(names) != len(flattened): + return None + + return tuple((name, uploaded.read(info)) for info, name in sorted(flattened, key=lambda member: member[1])) + + +def _common_root_prefix(paths: tuple[str, ...]) -> str: + roots: Final = frozenset(path.split("/", 1)[0] for path in paths) + if len(roots) != 1 or not all("/" in path for path in paths): + return "" + return f"{next(iter(roots))}/" + + +def _normalized_path(raw_path: str) -> str | None: + if not raw_path or "\0" in raw_path or "\\" in raw_path: + return None + if raw_path.startswith("/") or _WINDOWS_DRIVE_PATTERN.match(raw_path): + return None + parts: Final = tuple(part for part in raw_path.split("/") if part) + if not parts or any(part in (".", "..") for part in parts): + return None + return "/".join(parts) + + +def _manifest_frontmatter(manifest: bytes) -> Mapping[str, object]: + match: Final = _FRONTMATTER_PATTERN.match(manifest.decode("utf-8", errors="replace")) + if match is None: + return _EMPTY_FRONTMATTER + try: + parsed: Final = yaml.safe_load(match.group(1)) + except yaml.YAMLError: + return _EMPTY_FRONTMATTER + if not isinstance(parsed, dict): + return _EMPTY_FRONTMATTER + return parsed + + +def _frontmatter_text(frontmatter: Mapping[str, object], key: str) -> str | None: + value: Final = frontmatter.get(key) + if not isinstance(value, str): + return None + return value.strip() or None + + +def _zip_entry(name: str) -> zipfile.ZipInfo: + entry: Final = zipfile.ZipInfo(filename=name, date_time=_ZIP_ENTRY_TIMESTAMP) + entry.compress_type = zipfile.ZIP_DEFLATED + entry.external_attr = _ZIP_ENTRY_PERMISSIONS + return entry + + +def _repack(members: tuple[tuple[str, bytes], ...]) -> bytes: + buffer: Final = io.BytesIO() + with zipfile.ZipFile(buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as repacked: + for name, data in members: + repacked.writestr(_zip_entry(name), data) + return buffer.getvalue() diff --git a/litellm/proxy/discovery_endpoints/agent_skills_endpoints.py b/litellm/proxy/discovery_endpoints/agent_skills_endpoints.py new file mode 100644 index 00000000000..3084cbfd84f --- /dev/null +++ b/litellm/proxy/discovery_endpoints/agent_skills_endpoints.py @@ -0,0 +1,200 @@ +"""Serve skills stored on the proxy as an Agent Skills well-known discovery index. + +``npx skills add -a `` reads ``/.well-known/agent-skills/index.json`` +and downloads each entry's archive. Discovery clients send no credentials, so both +routes are unauthenticated and stay off until ``litellm_settings.public_skills_index`` +is enabled, which publishes every stored skill to anyone who can reach the proxy. +""" + +import asyncio +import re +from collections.abc import Sequence +from itertools import groupby +from operator import itemgetter +from types import MappingProxyType +from typing import Final + +from fastapi import APIRouter, Depends, HTTPException, Request, Response + +import litellm +from litellm._logging import verbose_proxy_logger +from litellm.caching.in_memory_cache import InMemoryCache +from litellm.models.skills import LiteLLM_SkillsTable +from litellm.proxy.discovery_endpoints.agent_skills_archive import SkillArchive, build_skill_archive +from litellm.types.proxy.discovery_endpoints.agent_skills_endpoints import ( + MAX_SKILL_DESCRIPTION_LENGTH, + MAX_SKILL_NAME_LENGTH, + AgentSkillsIndex, + AgentSkillsIndexEntry, +) + +MAX_INDEXED_SKILLS: Final = 1000 +MAX_CACHED_ARCHIVES: Final = 128 +MAX_CACHED_ARCHIVE_BYTES: Final = 512 * 1024 +ARCHIVE_CACHE_TTL_SECONDS: Final = 3600 + +_ARCHIVE_CACHE: Final = InMemoryCache( + max_size_in_memory=MAX_CACHED_ARCHIVES, + default_ttl=ARCHIVE_CACHE_TTL_SECONDS, + max_size_per_item=MAX_CACHED_ARCHIVE_BYTES // 1024, +) + +_NON_SLUG_PATTERN: Final = re.compile(r"[^a-z0-9]+") +_FALLBACK_SKILL_NAME: Final = "skill" + +router: Final = APIRouter(tags=["public", "skills"]) # mutable-ok: fastapi types tags as list[str | Enum] + + +class ZipArchiveResponse(Response): + """Response whose OpenAPI entry declares an application/zip download rather than JSON.""" + + media_type = "application/zip" + + +def ensure_index_enabled() -> None: + if litellm.public_skills_index is not True: + raise HTTPException(status_code=404, detail="Not Found") + + +async def stored_skills() -> Sequence[LiteLLM_SkillsTable]: + from litellm.llms.litellm_proxy.skills.handler import LiteLLMSkillsHandler + + return await LiteLLMSkillsHandler.list_skills(limit=MAX_INDEXED_SKILLS) + + +async def stored_skill(skill_id: str) -> LiteLLM_SkillsTable | None: + from litellm.llms.litellm_proxy.skills.handler import LiteLLMSkillsHandler + + try: + return await LiteLLMSkillsHandler.get_skill(skill_id) + except ValueError: + return None + + +@router.get( + "/.well-known/agent-skills/index.json", + response_model=AgentSkillsIndex, + dependencies=(Depends(ensure_index_enabled),), +) +@router.get( + "/.well-known/skills/index.json", + response_model=AgentSkillsIndex, + dependencies=(Depends(ensure_index_enabled),), + include_in_schema=False, +) +async def agent_skills_index( + request: Request, + skills: Sequence[LiteLLM_SkillsTable] = Depends(stored_skills), +) -> AgentSkillsIndex: + """Agent Skills v0.2.0 discovery index over every skill stored on this proxy.""" + from litellm.proxy.utils import get_custom_url + + installable: Final = await _installable(skills) + names: Final = _deduplicated(tuple(_base_name(skill, archive) for skill, archive in installable)) + + return AgentSkillsIndex( + skills=tuple( + AgentSkillsIndexEntry( + name=name, + type="archive", + description=_description(skill, archive, name), + url=get_custom_url( + request_base_url=str(request.base_url), + route=f"v1/skills/{skill.skill_id}/archive", + ), + digest=archive.digest, + ) + for (skill, archive), name in zip(installable, names, strict=True) + ) + ) + + +@router.get( + "/v1/skills/{skill_id}/archive", + dependencies=(Depends(ensure_index_enabled),), + response_class=ZipArchiveResponse, +) +async def agent_skills_archive( + skill_id: str, + skill: LiteLLM_SkillsTable | None = Depends(stored_skill), +) -> ZipArchiveResponse: + """Stored skill upload, repacked so SKILL.md sits at the archive root.""" + archive: Final = await _archive_for(skill) if skill is not None else None + if archive is None: + raise HTTPException(status_code=404, detail=f"No installable skill archive for: {skill_id}") + + return ZipArchiveResponse( + content=archive.content, + headers=MappingProxyType({"Content-Disposition": f'attachment; filename="{skill_id}.zip"'}), + ) + + +async def _installable( + skills: Sequence[LiteLLM_SkillsTable], +) -> tuple[tuple[LiteLLM_SkillsTable, SkillArchive], ...]: + built: Final = tuple([(skill, await _archive_for(skill)) for skill in reversed(skills)]) + return tuple((skill, archive) for skill, archive in built if archive is not None) + + +async def _archive_for(skill: LiteLLM_SkillsTable) -> SkillArchive | None: + if skill.file_content is None: + return None + + cache_key: Final = None if skill.updated_at is None else f"{skill.skill_id}:{skill.updated_at.isoformat()}" + cached: Final = None if cache_key is None else _ARCHIVE_CACHE.get_cache(cache_key) + if isinstance(cached, SkillArchive): + return cached + + archive: Final = await asyncio.to_thread(build_skill_archive, skill.file_content) + if archive is None: + verbose_proxy_logger.warning( + "Agent Skills index: skipping skill %s, its upload is not a zip holding SKILL.md at the root of a " + "single top-level folder", + skill.skill_id, + ) + return None + + if cache_key is not None and len(archive.content) <= MAX_CACHED_ARCHIVE_BYTES: + _ARCHIVE_CACHE.set_cache(cache_key, archive) + return archive + + +def _base_name(skill: LiteLLM_SkillsTable, archive: SkillArchive) -> str: + candidates: Final = (archive.declared_name, skill.display_title, skill.skill_id) + return next( + (slug for slug in (_slugify(candidate) for candidate in candidates) if slug is not None), + _FALLBACK_SKILL_NAME, + ) + + +def _slugify(raw: str | None) -> str | None: + if raw is None: + return None + return _NON_SLUG_PATTERN.sub("-", raw.lower()).strip("-")[:MAX_SKILL_NAME_LENGTH].rstrip("-") or None + + +def _deduplicated(names: Sequence[str]) -> tuple[str, ...]: + ordinals: Final = MappingProxyType( + { + position: ordinal + for _, duplicates in groupby(sorted(enumerate(names), key=itemgetter(1)), key=itemgetter(1)) + for ordinal, (position, _) in enumerate(duplicates) + } + ) + return tuple(_with_ordinal(name, ordinals[position]) for position, name in enumerate(names)) + + +def _with_ordinal(name: str, ordinal: int) -> str: + if ordinal == 0: + return name + suffix: Final = f"-{ordinal + 1}" + return f"{name[: MAX_SKILL_NAME_LENGTH - len(suffix)].rstrip('-')}{suffix}" + + +def _description(skill: LiteLLM_SkillsTable, archive: SkillArchive, name: str) -> str: + candidates: Final = (archive.declared_description, skill.description, skill.display_title) + chosen: Final = next( + (candidate.strip() for candidate in candidates if candidate is not None and candidate.strip()), + name, + ) + return chosen[:MAX_SKILL_DESCRIPTION_LENGTH] diff --git a/litellm/proxy/guardrails/guardrail_hooks/azure/prompt_shield.py b/litellm/proxy/guardrails/guardrail_hooks/azure/prompt_shield.py index de9618a44a1..a0724b75ec7 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/azure/prompt_shield.py +++ b/litellm/proxy/guardrails/guardrail_hooks/azure/prompt_shield.py @@ -341,6 +341,7 @@ class AzureContentSafetyPromptShieldGuardrail(AzureGuardrailBase, CustomGuardrai guardrail_response: Final = self._summarize_guardrail_response( response=response, original_inputs=original_inputs, + event_type=event_type, ) self.add_standard_logging_guardrail_information_to_request_data( guardrail_json_response=guardrail_response, diff --git a/litellm/proxy/guardrails/guardrail_hooks/conduct/__init__.py b/litellm/proxy/guardrails/guardrail_hooks/conduct/__init__.py new file mode 100644 index 00000000000..9eac143be88 --- /dev/null +++ b/litellm/proxy/guardrails/guardrail_hooks/conduct/__init__.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from collections.abc import Mapping +from types import MappingProxyType +from typing import TYPE_CHECKING, Final + +from litellm.types.guardrails import SupportedGuardrailIntegrations + +from .conduct import ConductGuardrail + +if TYPE_CHECKING: + from litellm.integrations.custom_guardrail import CustomGuardrail + from litellm.types.guardrails import Guardrail, LitellmParams + +DEFAULT_TIMEOUT_SECONDS: Final = 8.0 +_NO_EXTRAS: Final[Mapping[str, object]] = MappingProxyType({}) + + +def initialize_guardrail( + litellm_params: LitellmParams, + guardrail: Guardrail, + guardrail_cls: type[CustomGuardrail] = ConductGuardrail, +) -> CustomGuardrail: + import litellm + + extras: Final = litellm_params.model_extra or _NO_EXTRAS + _callback: Final = guardrail_cls( + api_url=litellm_params.api_base, + agent_token=litellm_params.api_key, + workspace_id=extras.get("workspace_id"), + tool_name=extras.get("tool_name", "llm_call"), + unreachable_fallback=litellm_params.unreachable_fallback, + timeout=DEFAULT_TIMEOUT_SECONDS if litellm_params.timeout is None else litellm_params.timeout, + guardrail_name=guardrail.get("guardrail_name", ""), + event_hook=litellm_params.mode, + default_on=litellm_params.default_on, + supported_event_hooks=guardrail_cls.get_supported_event_hooks(), + ) + litellm.logging_callback_manager.add_litellm_callback(_callback) + return _callback + + +guardrail_initializer_registry: Final = { # mutable-ok: module-level registry, built once and never mutated + SupportedGuardrailIntegrations.CONDUCT.value: initialize_guardrail, +} + +guardrail_class_registry: Final = { # mutable-ok: module-level registry, built once and never mutated + SupportedGuardrailIntegrations.CONDUCT.value: ConductGuardrail, +} diff --git a/litellm/proxy/guardrails/guardrail_hooks/conduct/conduct.py b/litellm/proxy/guardrails/guardrail_hooks/conduct/conduct.py new file mode 100644 index 00000000000..c87f8c016b1 --- /dev/null +++ b/litellm/proxy/guardrails/guardrail_hooks/conduct/conduct.py @@ -0,0 +1,158 @@ +"""Conduct Guard as a LiteLLM guardrail, backed by the ``conduct-litellm-guard`` PyPI package. + +Install: ``pip install "conduct-litellm-guard>=0.2.5"`` +Source: https://github.com/sseshachala/conductai/tree/main/packages/conduct-litellm-guard +""" + +from __future__ import annotations + +import inspect +from collections.abc import Awaitable, Callable, Mapping +from functools import partial +from types import MappingProxyType +from typing import TYPE_CHECKING, Final, Literal, Protocol + +from pydantic import BaseModel, ConfigDict + +from litellm.integrations.custom_guardrail import CustomGuardrail, log_guardrail_information +from litellm.types.llms.openai import ChatCompletionUserMessage +from litellm.types.proxy.guardrails.guardrail_hooks.conduct import ConductGuardrailConfigModel + +if TYPE_CHECKING: + from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj + from litellm.types.utils import GenericGuardrailAPIInputs, GuardrailStatus + +MISSING_PACKAGE_MESSAGE: Final = ( + "conduct-litellm-guard>=0.2.5 is required for the Conduct guardrail. " + 'Install it with: pip install "conduct-litellm-guard>=0.2.5"' +) + +BLOCKING_VERDICTS: Final = frozenset({"block", "approval"}) +FLAGGED_VERDICTS: Final = frozenset({"warning", "advisory"}) + + +class ConductDecision(Protocol): + @property + def verdict(self) -> str: ... + + @property + def rule_id(self) -> str | None: ... + + +class ConductCheck(Protocol): + def __call__(self, *, data: Mapping[str, object], call_type: str) -> Awaitable[ConductDecision]: ... + + +def request_payload( + inputs: GenericGuardrailAPIInputs, + request_data: Mapping[str, object], + input_type: Literal["request", "response"], +) -> Mapping[str, object] | None: + if input_type != "request": + return None + messages: Final = inputs.get("structured_messages") or tuple( + ChatCompletionUserMessage(role="user", content=text) for text in inputs.get("texts") or () + ) + return MappingProxyType({**request_data, "prompt": None, "messages": messages}) + + +def decision_status(decision: ConductDecision) -> GuardrailStatus: + return "guardrail_flagged" if decision.verdict in FLAGGED_VERDICTS else "success" + + +class ConductVerdict(BaseModel): + model_config = ConfigDict(frozen=True) + + verdict: str + rule_id: str | None = None + + +def record_decision( + guardrail: CustomGuardrail, + request_data: dict[str, object], # mutable-ok: the logging helper writes metadata into it + decision: ConductDecision, +) -> None: + guardrail.add_standard_logging_guardrail_information_to_request_data( + guardrail_json_response=ConductVerdict(verdict=decision.verdict, rule_id=decision.rule_id).model_dump(), + request_data=request_data, + guardrail_status=decision_status(decision), + ) + + +async def apply_conduct_guardrail( + inputs: GenericGuardrailAPIInputs, + request_data: Mapping[str, object], + input_type: Literal["request", "response"], + check: ConductCheck, + blocked: Callable[[ConductDecision], Exception], + record: Callable[[ConductDecision], None], +) -> GenericGuardrailAPIInputs: + payload: Final = request_payload(inputs, request_data, input_type) + if payload is None: + return inputs + decision: Final = await check(data=payload, call_type=input_type) + if decision.verdict in BLOCKING_VERDICTS: + raise blocked(decision) + record(decision) + return inputs + + +def binds_unreachable_fallback(guardrail_cls: type[object]) -> bool: + return "unreachable_fallback" in inspect.signature(guardrail_cls.__init__).parameters + + +try: + from conduct_litellm_guard.guardrail import ConductGuard, ConductGuardBlocked + + if not binds_unreachable_fallback(ConductGuard): + raise ImportError(MISSING_PACKAGE_MESSAGE) +except ImportError as import_error: + _import_error: Final = import_error + + class ConductGuardrail(CustomGuardrail): + def __init__(self, **kwargs: object) -> None: # kwargs-ok: mirrors the plugin constructor, only raises + raise ImportError(MISSING_PACKAGE_MESSAGE) from _import_error + + @staticmethod + def get_config_model() -> type[ConductGuardrailConfigModel]: + return ConductGuardrailConfigModel + +else: + + class ConductGuardrail(ConductGuard): # pyright: ignore[reportUntypedBaseClass] # optional dep, absent at type-check + @staticmethod + def get_config_model() -> type[ConductGuardrailConfigModel]: + return ConductGuardrailConfigModel + + @log_guardrail_information + async def apply_guardrail( + self, + inputs: GenericGuardrailAPIInputs, + request_data: dict[str, object], # mutable-ok: CustomGuardrail.apply_guardrail contract + input_type: Literal["request", "response"], + logging_obj: LiteLLMLoggingObj | None = None, + ) -> GenericGuardrailAPIInputs: + return await apply_conduct_guardrail( + inputs, + request_data, + input_type, + self.check, + ConductGuardBlocked, + partial(record_decision, self, request_data), + ) + + +__all__ = ( + "BLOCKING_VERDICTS", + "FLAGGED_VERDICTS", + "MISSING_PACKAGE_MESSAGE", + "ConductCheck", + "ConductDecision", + "ConductGuardrail", + "ConductVerdict", + "apply_conduct_guardrail", + "binds_unreachable_fallback", + "decision_status", + "record_decision", + "request_payload", +) diff --git a/litellm/proxy/guardrails/guardrail_hooks/unified_guardrail/unified_guardrail.py b/litellm/proxy/guardrails/guardrail_hooks/unified_guardrail/unified_guardrail.py index 64a47f4f4ff..ee5cd7c4cb8 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/unified_guardrail/unified_guardrail.py +++ b/litellm/proxy/guardrails/guardrail_hooks/unified_guardrail/unified_guardrail.py @@ -315,7 +315,6 @@ class UnifiedLLMGuardrails(CustomLogger): if call_type is None: call_type = _infer_call_type(call_type=None, completion_response=response) - # Fallback: resolve call_type from logging_obj for pass-through endpoints if call_type is None: litellm_logging_obj: Final = data.get("litellm_logging_obj") logging_call_type: Final = ( @@ -324,6 +323,8 @@ class UnifiedLLMGuardrails(CustomLogger): if logging_call_type in ( CallTypes.pass_through.value, CallTypes.allm_passthrough_route.value, + CallTypes.ocr.value, + CallTypes.aocr.value, ): call_type = logging_call_type diff --git a/litellm/proxy/hooks/key_management_event_hooks.py b/litellm/proxy/hooks/key_management_event_hooks.py index cdaa6d5a81c..5cfef11df8d 100644 --- a/litellm/proxy/hooks/key_management_event_hooks.py +++ b/litellm/proxy/hooks/key_management_event_hooks.py @@ -3,6 +3,8 @@ import json from datetime import datetime, timezone from typing import Final +from pydantic import TypeAdapter + import litellm from litellm._logging import verbose_proxy_logger from litellm._uuid import uuid @@ -108,30 +110,32 @@ class KeyManagementEventHooks: from litellm.proxy.proxy_server import litellm_proxy_admin_name if is_audit_logging_enabled(): - _updated_values: Final = json.dumps(data.json(exclude_none=True), default=str) - - _before_value = existing_key_row.json(exclude_none=True) - _before_value = json.dumps(_before_value, default=str) - - asyncio.create_task( - create_audit_log_for_update( - request_data=LiteLLM_AuditLogs( - id=str(uuid.uuid4()), - updated_at=datetime.now(timezone.utc), - changed_by=get_audit_log_changed_by( - litellm_changed_by=litellm_changed_by, - user_api_key_dict=user_api_key_dict, - litellm_proxy_admin_name=litellm_proxy_admin_name, - ), - changed_by_api_key=user_api_key_dict.api_key, - table_name=LitellmTableNames.KEY_TABLE_NAME, - object_id=_hash_token_if_needed(data.key), - action="updated", - updated_values=_updated_values, - before_value=_before_value, - ) - ) + updated_fields: Final = { + **data.model_dump(exclude_none=True), + **({"project_id": data.project_id} if "project_id" in data.model_fields_set else {}), + } + audit_log: Final = LiteLLM_AuditLogs( + id=str(uuid.uuid4()), + updated_at=datetime.now(timezone.utc), + changed_by=get_audit_log_changed_by( + litellm_changed_by=litellm_changed_by, + user_api_key_dict=user_api_key_dict, + litellm_proxy_admin_name=litellm_proxy_admin_name, + ), + changed_by_api_key=user_api_key_dict.api_key, + table_name=LitellmTableNames.KEY_TABLE_NAME, + object_id=_hash_token_if_needed(data.key), + action="updated", + updated_values=json.dumps(updated_fields, default=str), + before_value=json.dumps(existing_key_row.json(exclude_none=True), default=str), ) + masked_values: Final = TypeAdapter(dict[str, object]).validate_json(str(audit_log.updated_values)) + request_data: Final = ( + audit_log.model_copy(update={"updated_values": json.dumps({**masked_values, "project_id": None})}) + if "project_id" in data.model_fields_set and data.project_id is None + else audit_log + ) + asyncio.create_task(create_audit_log_for_update(request_data=request_data)) @staticmethod async def async_key_rotated_hook( diff --git a/litellm/proxy/management_endpoints/budget_management_endpoints.py b/litellm/proxy/management_endpoints/budget_management_endpoints.py index 62a24109dbb..81a607aaa43 100644 --- a/litellm/proxy/management_endpoints/budget_management_endpoints.py +++ b/litellm/proxy/management_endpoints/budget_management_endpoints.py @@ -14,6 +14,7 @@ All /budget management endpoints #### BUDGET TABLE MANAGEMENT #### import math from collections.abc import Mapping +from types import MappingProxyType from typing import Final from fastapi import APIRouter, Depends, HTTPException @@ -176,6 +177,10 @@ async def update_budget( recomputed_reset_at: Final = ( {"budget_reset_at": get_budget_reset_time(budget_duration=budget_obj.budget_duration)} if budget_obj.budget_duration is not None and "budget_reset_at" not in budget_obj.model_fields_set + else MappingProxyType({"budget_reset_at": None}) + if "budget_duration" in budget_obj.model_fields_set + and budget_obj.budget_duration is None + and "budget_reset_at" not in budget_obj.model_fields_set else {} ) diff --git a/litellm/proxy/management_endpoints/common_daily_activity.py b/litellm/proxy/management_endpoints/common_daily_activity.py index a8aef30107c..44ed0017e42 100644 --- a/litellm/proxy/management_endpoints/common_daily_activity.py +++ b/litellm/proxy/management_endpoints/common_daily_activity.py @@ -127,6 +127,7 @@ def _key_metadata(api_key_metadata: Mapping[str, _KeyMetadataDict], api_key: str return KeyMetadata( key_alias=meta.get("key_alias"), team_id=meta.get("team_id"), + user_id=meta.get("user_id"), user_email=meta.get("user_email"), ) diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index 10c11119006..000b7f874ee 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -1254,8 +1254,8 @@ def _update_internal_user_params(data_json: dict, data: UpdateUserRequest | Upda fields_set: Final = data.fields_set() if hasattr(data, "fields_set") else set() for k, v in data_json.items(): - if k == "max_budget": - if "max_budget" in fields_set: + if k in ("max_budget", "budget_duration"): + if k in fields_set: non_default_values[k] = v elif k == "model_max_budget": if k in fields_set: @@ -1283,8 +1283,10 @@ def _update_internal_user_params(data_json: dict, data: UpdateUserRequest | Upda from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time validate_budget_duration(non_default_values["budget_duration"]) - non_default_values["budget_reset_at"] = get_budget_reset_time( - budget_duration=non_default_values["budget_duration"] + non_default_values["budget_reset_at"] = ( + get_budget_reset_time(budget_duration=non_default_values["budget_duration"]) + if non_default_values["budget_duration"] is not None + else None ) if "max_budget" not in non_default_values: diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index db4467dda5b..1fe174763c4 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -2718,6 +2718,12 @@ async def _validate_update_key_data( user_api_key_dict=user_api_key_dict, ) + if data.project_id is not None and data.project_id != existing_key_row.project_id: + raise HTTPException( + status_code=400, detail="Project reassignment is not supported. Use null to detach the key." + ) + is_project_change: Final = "project_id" in data.model_fields_set and data.project_id != existing_key_row.project_id + common_key_access_checks( user_api_key_dict=user_api_key_dict, data=data, @@ -2810,7 +2816,9 @@ async def _validate_update_key_data( # non-budget change means the caller was authorized — skip the redundant # _check_key_admin_access that would otherwise require team/org admin status. _key_is_team_key: Final = getattr(existing_key_row, "team_id", None) is not None - can_skip_admin_check: Final = (caller_is_creator or _key_is_team_key) and not _is_budget_change + can_skip_admin_check: Final = (caller_is_creator or _key_is_team_key) and not ( + _is_budget_change or is_project_change + ) if (not _is_proxy_admin) and not can_skip_admin_check: hashed_key: Final = existing_key_row.token await _check_key_admin_access( @@ -2853,7 +2861,9 @@ async def _validate_update_key_data( ) # Validate key against project limits if project_id is being set - _project_id_to_check: Final = getattr(data, "project_id", None) or getattr(existing_key_row, "project_id", None) + _project_id_to_check: Final = ( + data.project_id if "project_id" in data.model_fields_set else existing_key_row.project_id + ) if _project_id_to_check is not None and (data.models is not None or data.max_budget is not None): await _check_project_key_limits( project_id=_project_id_to_check, @@ -2962,6 +2972,7 @@ async def update_key_fn( - user_id: Optional[str] - User ID associated with key - team_id: Optional[str] - Team ID associated with key - agent_id: Optional[str] - The agent id associated with the key. + - project_id: Optional[str] - Omit to retain the project, or send null to detach. A different project ID is rejected. - organization_id: Optional[str] - The organization id of the key. - budget_id: Optional[str] - The budget id associated with the key. Created by calling `/budget/new`. - models: Optional[list] - Model_name's a user is allowed to call diff --git a/litellm/proxy/management_endpoints/model_management_endpoints.py b/litellm/proxy/management_endpoints/model_management_endpoints.py index 94d2b773e14..8484279c69a 100644 --- a/litellm/proxy/management_endpoints/model_management_endpoints.py +++ b/litellm/proxy/management_endpoints/model_management_endpoints.py @@ -763,6 +763,15 @@ def update_db_model(db_model: Deployment, updated_patch: updateDeployment) -> Pr if field in SPECIAL_MODEL_INFO_PARAMS and getattr(updated_patch.litellm_params, field) is None: merged_litellm_params.pop(field, None) merged_model_info.pop(field, None) + elif ( + field + in ( + "auto_router_routing_compression", + "auto_router_model_compression", + ) + and getattr(updated_patch.litellm_params, field) is None + ): + merged_litellm_params.pop(field, None) if updated_patch.model_info: for field in updated_patch.model_info.model_fields_set: if field in SPECIAL_MODEL_INFO_PARAMS and getattr(updated_patch.model_info, field) is None: diff --git a/litellm/proxy/management_endpoints/tag_management_endpoints.py b/litellm/proxy/management_endpoints/tag_management_endpoints.py index b74aa1a4e16..ab33d4bd766 100644 --- a/litellm/proxy/management_endpoints/tag_management_endpoints.py +++ b/litellm/proxy/management_endpoints/tag_management_endpoints.py @@ -438,6 +438,7 @@ async def update_tag( user_api_key_dict=user_api_key_dict, prisma_client=prisma_client, litellm_proxy_admin_name=litellm_proxy_admin_name, + budget_duration_cleared="budget_duration" in tag.model_fields_set and tag.budget_duration is None, ) # Get model names for model_info diff --git a/litellm/proxy/management_helpers/utils.py b/litellm/proxy/management_helpers/utils.py index e2d7262fb69..f3bd4b0f6dd 100644 --- a/litellm/proxy/management_helpers/utils.py +++ b/litellm/proxy/management_helpers/utils.py @@ -3,6 +3,7 @@ from collections.abc import Callable, Mapping, MutableMapping, Sequence from datetime import datetime from functools import wraps +from types import MappingProxyType from typing import Any, Final, Protocol from fastapi import HTTPException, Request @@ -180,6 +181,7 @@ async def handle_budget_for_entity( user_api_key_dict: UserAPIKeyAuth, prisma_client: PrismaClient, litellm_proxy_admin_name: str, + budget_duration_cleared: bool = False, ) -> str | None: """ Common helper to handle budget creation/updates for entities (organizations, tags, etc). @@ -208,7 +210,14 @@ async def handle_budget_for_entity( # Extract budget fields from data _json_data: Final = data.model_dump(exclude_none=True) if hasattr(data, "model_dump") else data - _budget_data: Final = {k: v for k, v in _json_data.items() if k in budget_params} + _budget_data: Final = MappingProxyType( + { + k: _json_data.get(k) + for k in budget_params + if k in _json_data + or (k == "budget_duration" and existing_budget_id is not None and budget_duration_cleared) + } + ) # Check if budget_id is explicitly provided in the data data_budget_id: Final[str | None] = getattr(data, "budget_id", None) diff --git a/litellm/proxy/mcp_registry.json b/litellm/proxy/mcp_registry.json index f37fc39813e..b117f35600d 100644 --- a/litellm/proxy/mcp_registry.json +++ b/litellm/proxy/mcp_registry.json @@ -66,7 +66,7 @@ "name": "slack", "title": "Slack", "description": "Channel management, messaging, and Slack workspace integration", - "icon_url": "https://cdn.simpleicons.org/slack", + "icon_url": "/ui/assets/logos/slack.svg", "category": "Communication", "registry_url": null, "transport": "stdio", @@ -249,7 +249,7 @@ "name": "exa", "title": "Exa", "description": "Fast, intelligent web search and web crawling", - "icon_url": "https://cdn.simpleicons.org/exa", + "icon_url": "/ui/assets/logos/exa_ai.png", "category": "Search", "registry_url": "https://registry.modelcontextprotocol.io/servers/ai.exa%2Fexa", "transport": "http", @@ -262,7 +262,7 @@ "name": "tavily", "title": "Tavily", "description": "AI-optimized search engine for research and retrieval", - "icon_url": "https://cdn.simpleicons.org/tavily", + "icon_url": "/ui/assets/logos/tavily.png", "category": "Search", "registry_url": null, "transport": "stdio", @@ -288,7 +288,7 @@ "name": "playwright", "title": "Playwright", "description": "Browser automation and testing with Playwright", - "icon_url": "https://cdn.simpleicons.org/playwright", + "icon_url": "https://raw.githubusercontent.com/microsoft/playwright/2f6148bcd1a96ec687d55ce08645fc6315b1514e/packages/recorder/public/playwright-logo.svg", "category": "Web & Browser", "registry_url": null, "transport": "stdio", @@ -300,7 +300,7 @@ "name": "browserbase", "title": "Browserbase", "description": "Cloud browser automation and session management", - "icon_url": "https://cdn.simpleicons.org/browserbase", + "icon_url": "https://www.browserbase.com/favicon.svg", "category": "Web & Browser", "registry_url": null, "transport": "stdio", @@ -315,7 +315,7 @@ "name": "aws", "title": "AWS", "description": "Interact with Amazon Web Services resources and APIs", - "icon_url": "https://cdn.simpleicons.org/amazonaws", + "icon_url": "/ui/assets/logos/aws.svg", "category": "Cloud", "registry_url": null, "transport": "stdio", @@ -392,7 +392,7 @@ "name": "twilio", "title": "Twilio", "description": "Send SMS, make calls, and manage communication via Twilio", - "icon_url": "https://cdn.simpleicons.org/twilio", + "icon_url": "/ui/assets/logos/twilio.svg", "category": "Communication", "registry_url": null, "transport": "stdio", diff --git a/litellm/proxy/ocr_endpoints/endpoints.py b/litellm/proxy/ocr_endpoints/endpoints.py index ebf4d988fdd..53ebbe91b54 100644 --- a/litellm/proxy/ocr_endpoints/endpoints.py +++ b/litellm/proxy/ocr_endpoints/endpoints.py @@ -15,7 +15,7 @@ from litellm.llms.base_llm.ocr.transformation import ( OCRResponse, parse_ocr_request_format, ) -from litellm.ocr.main import convert_file_document_to_url_document, get_mime_type +from litellm.ocr.input import convert_upload_to_url_document, get_max_file_bytes from litellm.proxy._types import * from litellm.proxy.auth.user_api_key_auth import UserAPIKeyAuth, user_api_key_auth from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing @@ -28,24 +28,7 @@ def _build_document_from_upload( filename: str | None, content_type: str | None, ) -> dict[str, str]: - """ - Convert uploaded file bytes into a Mistral-format document dict with base64 data URI. - - Delegates to convert_file_document_to_url_document after resolving MIME type - from the upload's content_type header or filename. - """ - mime_type = content_type.split(";")[0].strip() if content_type else None - if not mime_type or mime_type == "application/octet-stream": - if filename: - mime_type = get_mime_type(filename) - - return convert_file_document_to_url_document( - { - "type": "file", - "file": file_content, - "mime_type": mime_type or "application/octet-stream", - } - ) + return convert_upload_to_url_document(file_content, filename, content_type) def _with_request_format(data: Mapping[str, Any], request: Request) -> Mapping[str, Any]: @@ -120,7 +103,7 @@ async def _parse_multipart_form(request: Request) -> dict[str, Any]: # Seek to start in case the file was already partially read by middleware await uploaded_file.seek(0) - file_content: Final = await uploaded_file.read() + file_content: Final = await uploaded_file.read(get_max_file_bytes() + 1) if not file_content: raise ValueError("Uploaded file is empty") diff --git a/litellm/proxy/pass_through_endpoints/llm_provider_handlers/anthropic_passthrough_logging_handler.py b/litellm/proxy/pass_through_endpoints/llm_provider_handlers/anthropic_passthrough_logging_handler.py index 30b75a7b482..7cec3bac207 100644 --- a/litellm/proxy/pass_through_endpoints/llm_provider_handlers/anthropic_passthrough_logging_handler.py +++ b/litellm/proxy/pass_through_endpoints/llm_provider_handlers/anthropic_passthrough_logging_handler.py @@ -216,11 +216,14 @@ class AnthropicPassthroughLoggingHandler: model=model, speed=AnthropicPassthroughLoggingHandler._cost_relevant_speed(request_body), ) - if response is None: - return None - AnthropicPassthroughLoggingHandler._recover_interrupted_stream_output_tokens( + if not isinstance(response, ModelResponse): + return response + recovered_usage: Final = AnthropicPassthroughLoggingHandler._recover_interrupted_stream_output_tokens( response=response, all_chunks=all_chunks, model=model ) + if recovered_usage is None: + return response + AnthropicPassthroughLoggingHandler._clear_placeholder_cost(response=response, usage=recovered_usage) return response @staticmethod @@ -259,7 +262,9 @@ class AnthropicPassthroughLoggingHandler: ) except Exception as e: # noqa: BLE001 # an uncostable partial stream still bills its tokens, at zero cost verbose_proxy_logger.warning( - "Anthropic passthrough: could not cost the partial usage of a failed stream (model=%s): %s", model, e + "Anthropic passthrough: could not cost the partial usage of an interrupted stream (model=%s): %s", + model, + e, ) return 0.0 @@ -359,7 +364,7 @@ class AnthropicPassthroughLoggingHandler: response: ModelResponse | TextCompletionResponse, all_chunks: Sequence[str | bytes], model: str, - ) -> None: + ) -> Usage | None: """ An Anthropic stream interrupted before its terminal ``message_delta`` (client disconnect) carries only the ``message_start`` ``output_tokens`` @@ -369,24 +374,24 @@ class AnthropicPassthroughLoggingHandler: untouched because their terminal ``message_delta`` short-circuits here. """ if not isinstance(response, ModelResponse): - return + return None if not AnthropicPassthroughLoggingHandler._stream_was_interrupted(all_chunks): - return + return None usage: Final = getattr(response, "usage", None) - if usage is None: - return + if not isinstance(usage, Usage): + return None output_text: Final = get_content_from_model_response(response) if not output_text: - return + return None try: recovered_output_tokens = litellm.token_counter(model=model, text=output_text, count_response_tokens=True) except Exception: verbose_proxy_logger.warning( "Could not re-tokenize interrupted stream output; keeping placeholder completion token count." ) - return + return None if recovered_output_tokens <= (usage.completion_tokens or 0): - return + return None usage.completion_tokens = recovered_output_tokens usage.total_tokens = (usage.prompt_tokens or 0) + recovered_output_tokens # Anthropic costing reads completion_tokens_details.text_tokens, so the @@ -395,6 +400,12 @@ class AnthropicPassthroughLoggingHandler: details: Final = getattr(usage, "completion_tokens_details", None) if details is not None and getattr(details, "text_tokens", None) is not None: details.text_tokens = recovered_output_tokens + return usage + + @staticmethod + def _clear_placeholder_cost(response: ModelResponse, usage: Usage) -> None: + usage.cost = None + response._hidden_params.pop("response_cost", None) # pyright: ignore[reportPrivateUsage] # no public accessor @staticmethod def _create_anthropic_response_logging_payload( diff --git a/litellm/proxy/policy_engine/attachment_registry.py b/litellm/proxy/policy_engine/attachment_registry.py index a8ead86ac36..76b2291774e 100644 --- a/litellm/proxy/policy_engine/attachment_registry.py +++ b/litellm/proxy/policy_engine/attachment_registry.py @@ -6,6 +6,7 @@ This allows the same policy to be attached to multiple scopes. """ from datetime import datetime, timezone +from types import MappingProxyType from typing import TYPE_CHECKING, Any, Final, TypedDict from litellm._logging import verbose_proxy_logger @@ -141,8 +142,11 @@ class AttachmentRegistry: ), key=_attachment_specificity, ) + broadest_attachment_by_policy: Final = MappingProxyType( + {attachment.policy: attachment for attachment in reversed(matching_attachments)} + ) unique_attachments: Final = tuple( - next(attachment for attachment in matching_attachments if attachment.policy == policy_name) + broadest_attachment_by_policy[policy_name] for policy_name in dict.fromkeys(attachment.policy for attachment in matching_attachments) ) diff --git a/litellm/proxy/prisma_migration.py b/litellm/proxy/prisma_migration.py index 1b95d24c011..7e3aff75cef 100644 --- a/litellm/proxy/prisma_migration.py +++ b/litellm/proxy/prisma_migration.py @@ -14,6 +14,8 @@ sys.path.insert(0, os.path.abspath("./")) from typing import Final +from litellm_proxy_extras.prisma_toolchain import resolve_prisma_argv + from litellm._logging import verbose_proxy_logger from litellm.proxy.proxy_cli import run_server from litellm.secret_managers.main import str_to_bool @@ -29,7 +31,7 @@ def main() -> int: run_server(run_server_args, standalone_mode=False) verbose_proxy_logger.info("Running 'prisma generate'...") - result: Final = subprocess.run(("prisma", "generate"), capture_output=True, text=True) + result: Final = subprocess.run(resolve_prisma_argv(("prisma", "generate")), capture_output=True, text=True) verbose_proxy_logger.info("'prisma generate' stdout: %s", result.stdout) if result.returncode != 0: diff --git a/litellm/proxy/proxy_cli.py b/litellm/proxy/proxy_cli.py index c2d60cd5488..01a3da08998 100644 --- a/litellm/proxy/proxy_cli.py +++ b/litellm/proxy/proxy_cli.py @@ -1267,73 +1267,69 @@ def run_server( flush=True, ) sys.exit(1) - try: - from litellm.secret_managers.main import get_secret + from litellm.secret_managers.main import get_secret - connection_url_params: Final = _build_db_connection_url_params( - connection_limit=db_connection_pool_limit, - pool_timeout=db_connection_timeout, - connect_timeout=db_connect_timeout, - socket_timeout=db_socket_timeout, - disable_prepared_statements=db_disable_prepared_statements, - extra_params=db_extra_connection_params, + connection_url_params: Final = _build_db_connection_url_params( + connection_limit=db_connection_pool_limit, + pool_timeout=db_connection_timeout, + connect_timeout=db_connect_timeout, + socket_timeout=db_socket_timeout, + disable_prepared_statements=db_disable_prepared_statements, + extra_params=db_extra_connection_params, + ) + lifetime_params: Final = idle_lifetime_params(general_settings.get("database_max_idle_connection_lifetime")) + if os.getenv("DATABASE_URL", None) is not None: + database_url = get_secret("DATABASE_URL", default_value=None) + resolved_url: Final[str | None] = str(database_url) if database_url else None + pg_options: Final[str] = _pg_options_with_timeouts( + _url_query_value(resolved_url, "options"), + db_statement_timeout, + db_lock_timeout, ) - lifetime_params: Final = idle_lifetime_params( - general_settings.get("database_max_idle_connection_lifetime") + writer_url: Final = ( + _with_query_value(resolved_url, "options", pg_options) + if resolved_url and pg_options + else resolved_url ) - if os.getenv("DATABASE_URL", None) is not None: - database_url = get_secret("DATABASE_URL", default_value=None) - resolved_url: Final[str | None] = str(database_url) if database_url else None - pg_options: Final[str] = _pg_options_with_timeouts( - _url_query_value(resolved_url, "options"), - db_statement_timeout, - db_lock_timeout, - ) - writer_url: Final = ( - _with_query_value(resolved_url, "options", pg_options) - if resolved_url and pg_options - else resolved_url - ) - modified_url = append_query_params( - writer_url, - connection_url_params, - ) - os.environ["DATABASE_URL"] = translate_libpq_ssl_params( - add_missing_query_params(modified_url, lifetime_params) - ) - if os.getenv("DIRECT_URL", None) is not None: - database_url = os.getenv("DIRECT_URL") - modified_url = append_query_params(database_url, connection_url_params) - os.environ["DIRECT_URL"] = translate_libpq_ssl_params( - add_missing_query_params(modified_url, lifetime_params) - ) - # The reader pool is a real pool against the same configured cap, so it - # gets the allowlisted pool params. Schema-affecting ones, including any - # the operator smuggled in through database_extra_connection_params, stay - # on the writer. Anything pinned on the replica URL wins, unlike the - # writer where the config is applied on top. - read_replica_url: Final[str | None] = os.getenv("DATABASE_URL_READ_REPLICA") - if read_replica_url: - reader_options: Final[str] = _pg_options_with_timeouts( - _url_query_value(read_replica_url, "options"), - db_statement_timeout, - db_lock_timeout, - ) - os.environ["DATABASE_URL_READ_REPLICA"] = translate_libpq_ssl_params( + modified_url = append_query_params( + writer_url, + connection_url_params, + ) + os.environ["DATABASE_URL"] = translate_libpq_ssl_params( + add_missing_query_params(modified_url, lifetime_params) + ) + if os.getenv("DIRECT_URL", None) is not None: + database_url = os.getenv("DIRECT_URL") + modified_url = append_query_params(database_url, connection_url_params) + os.environ["DIRECT_URL"] = translate_libpq_ssl_params( + add_missing_query_params(modified_url, lifetime_params) + ) + # The reader pool is a real pool against the same configured cap, so it + # gets the allowlisted pool params. Schema-affecting ones, including any + # the operator smuggled in through database_extra_connection_params, stay + # on the writer. Anything pinned on the replica URL wins, unlike the + # writer where the config is applied on top. + read_replica_url: Final[str | None] = os.getenv("DATABASE_URL_READ_REPLICA") + if read_replica_url: + reader_options: Final[str] = _pg_options_with_timeouts( + _url_query_value(read_replica_url, "options"), + db_statement_timeout, + db_lock_timeout, + ) + os.environ["DATABASE_URL_READ_REPLICA"] = translate_libpq_ssl_params( + add_missing_query_params( add_missing_query_params( - add_missing_query_params( - _with_query_value(read_replica_url, "options", reader_options) - if reader_options - else read_replica_url, - reader_shareable_params(connection_url_params), - ), - lifetime_params, - ) + _with_query_value(read_replica_url, "options", reader_options) + if reader_options + else read_replica_url, + reader_shareable_params(connection_url_params), + ), + lifetime_params, ) - subprocess.run(["prisma"], capture_output=True) - is_prisma_runnable = True - except FileNotFoundError: - is_prisma_runnable = False + ) + from litellm_proxy_extras.prisma_toolchain import prisma_cli_available + + is_prisma_runnable: Final = prisma_cli_available() if is_prisma_runnable: from litellm.proxy.db.check_migration import check_prisma_schema_diff @@ -1382,7 +1378,8 @@ def run_server( ) else: print( - f"Unable to connect to DB. DATABASE_URL found in environment, but prisma package not found." # noqa: F541 + "Unable to connect to DB. DATABASE_URL found in environment, but the prisma CLI is neither on " + "PATH nor importable as a package." ) pgbouncer_settings: Final = PgBouncerSettings() upstream_database_url: Final = os.getenv("DATABASE_URL") diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 606a590c24b..d9f8e04ebda 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -353,6 +353,7 @@ from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import ( AuthCacheInvalidationSubscriber, ) from litellm.proxy.common_utils.callback_utils import initialize_callbacks_on_proxy +from litellm.proxy.common_utils.config_includes import resolve_include_file_path, resolve_includes from litellm.proxy.common_utils.config_sync_pubsub import ConfigSyncSubscriber from litellm.proxy.common_utils.debug_utils import init_verbose_loggers from litellm.proxy.common_utils.debug_utils import router as debugging_endpoints_router @@ -371,10 +372,7 @@ from litellm.proxy.common_utils.http_parsing_utils import ( check_file_size_under_limit, get_form_data, ) -from litellm.proxy.common_utils.load_config_utils import ( - get_config_file_contents_from_gcs, - get_file_contents_from_s3, -) +from litellm.proxy.common_utils.load_config_utils import get_config_from_bucket from litellm.proxy.common_utils.model_deprecation import collect_model_deprecations from litellm.proxy.common_utils.model_listing_utils import ( ClaudeCodeRoutingNames, @@ -448,7 +446,10 @@ from litellm.proxy.db.proxy_worker_heartbeat import ( ProxyWorkerHeartbeat, ) from litellm.proxy.db.spend_counter_reseed import END_USER_COUNTER_PREFIX, SpendCounterReseed -from litellm.proxy.discovery_endpoints import ui_discovery_endpoints_router +from litellm.proxy.discovery_endpoints import ( + agent_skills_discovery_router, + ui_discovery_endpoints_router, +) from litellm.proxy.fine_tuning_endpoints.endpoints import router as fine_tuning_router from litellm.proxy.fine_tuning_endpoints.endpoints import set_fine_tuning_config from litellm.proxy.google_endpoints.endpoints import router as google_router @@ -4803,12 +4804,12 @@ class ProxyConfig: if config is None: raise Exception("Config cannot be None or Empty.") # Process includes - config = self._process_includes(config=config, base_dir=os.path.dirname(os.path.abspath(file_path or ""))) + config = await self._process_includes(config=config, config_file_path=os.path.abspath(file_path or "")) # verbose_proxy_logger.debug(f"loaded config={json.dumps(config, indent=4)}") return config - def _process_includes(self, config: dict, base_dir: str) -> dict: + async def _process_includes(self, config: dict, config_file_path: str) -> dict: """ Process includes by appending their contents to the main config @@ -4823,29 +4824,21 @@ class ProxyConfig: callbacks: ["prometheus"] ``` """ - if "include" not in config: - return config - if not isinstance(config["include"], list): - raise ValueError("'include' must be a list of file paths") + included_config_adapter: Final = TypeAdapter(dict[str, object]) - # Load and append all included files - for include_file in config["include"]: - file_path = os.path.join(base_dir, include_file) + def resolve(include_file: str, declared_in: str) -> str: + return resolve_include_file_path(include_file, declared_in, config_file_path) + + async def read_included(file_path: str) -> Mapping[str, object]: if not os.path.exists(file_path): raise FileNotFoundError(f"Included file not found: {file_path}") + try: + return included_config_adapter.validate_python(self._load_yaml_file(file_path)) + except ValidationError as e: + raise ValueError(f"Included config file is not a YAML mapping: {file_path}") from e - included_config = self._load_yaml_file(file_path) - # Simply update/extend the main config with included config - for key, value in included_config.items(): - if isinstance(value, list) and key in config: - config[key].extend(value) - else: - config[key] = value - - # Remove the include directive - del config["include"] - return config + 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): global prisma_client, general_settings, user_config_file_path, store_model_in_db @@ -5203,15 +5196,19 @@ class ProxyConfig: global prisma_client, store_model_in_db # Load existing config - if os.environ.get("LITELLM_CONFIG_BUCKET_NAME") is not None: - bucket_name: Final = os.environ.get("LITELLM_CONFIG_BUCKET_NAME") + bucket_name: Final = os.environ.get("LITELLM_CONFIG_BUCKET_NAME") + if bucket_name is not None: object_key: Final = os.environ.get("LITELLM_CONFIG_BUCKET_OBJECT_KEY") bucket_type: Final = os.environ.get("LITELLM_CONFIG_BUCKET_TYPE") verbose_proxy_logger.debug("bucket_name: %s, object_key: %s", bucket_name, object_key) - if bucket_type == "gcs": - config = await get_config_file_contents_from_gcs(bucket_name=bucket_name, object_key=object_key) - else: - config = get_file_contents_from_s3(bucket_name=bucket_name, object_key=object_key) + if object_key is None: + raise Exception("LITELLM_CONFIG_BUCKET_OBJECT_KEY must be set to load the config from a bucket.") + + config = await get_config_from_bucket( + bucket_type=bucket_type, + bucket_name=bucket_name, + object_key=object_key, + ) if config is None: raise Exception("Unable to load config from given source.") @@ -18798,6 +18795,7 @@ app.include_router(user_agent_analytics_router) app.include_router(gateway_request_router) app.include_router(enterprise_router) app.include_router(ui_discovery_endpoints_router) +app.include_router(agent_skills_discovery_router) # Eager: /models/{name}:method overlaps with the OpenAI /models endpoint. app.include_router(google_router) diff --git a/litellm/rust_bridge/configuration.py b/litellm/rust_bridge/configuration.py index 5582027bb5d..ff2e389a6bb 100644 --- a/litellm/rust_bridge/configuration.py +++ b/litellm/rust_bridge/configuration.py @@ -42,6 +42,17 @@ def rust_enabled() -> bool: ) +def rust_ocr_enabled() -> bool: + environment: Final = _parse_env_bool(os.getenv(_GLOBAL_ENV_NAME)) + if environment is False: + return False + return resolve_rust_enabled( + process_override=_CONFIGURATION.override, + environment_override=environment, + release_default=True, + ) + + def reset_rust_configuration() -> None: _CONFIGURATION.override = None diff --git a/litellm/rust_bridge/lifecycle.py b/litellm/rust_bridge/lifecycle.py new file mode 100644 index 00000000000..f5e0c1b0fc6 --- /dev/null +++ b/litellm/rust_bridge/lifecycle.py @@ -0,0 +1,215 @@ +from __future__ import annotations + +import datetime +import os +import uuid +from collections.abc import Awaitable, Mapping +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 + + +@dataclass(frozen=True, slots=True) +class Await: + awaitable: Awaitable[object] + + +@dataclass(frozen=True, slots=True) +class Complete: + value: object + + +class Execution(Protocol): + def start(self) -> Await | Complete: ... + + def resume_value(self, value: object) -> Await | Complete: ... + + def resume_error(self, error: BaseException) -> Await | Complete: ... + + def close(self) -> None: ... + + +async def drive(execution: Execution) -> object: + try: + step = execution.start() # rebind-ok: the execution protocol advances after each selected await + while isinstance(step, Await): + try: + value = await step.awaitable # rebind-ok: each selected await produces the next protocol input + except GeneratorExit: + raise + except BaseException as error: + step = execution.resume_error(error) # rebind-ok: advance the execution protocol + else: + step = execution.resume_value(value) # rebind-ok: advance the execution protocol + 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 + + 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) + metadata: Final = kwargs.get("metadata") + if isinstance(metadata, Mapping): + typed_metadata: Final = cast( # cast-ok: runtime Mapping check establishes read-only metadata + Mapping[str, object], metadata + ) + previous: Final = typed_metadata.get("previous_models") + if ( + isinstance(previous, list) + and litellm.num_retries_per_request is not None + and len(cast(list[object], previous)) # cast-ok: runtime list check establishes the retry history + >= 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/ocr.py b/litellm/rust_bridge/ocr.py index 89eab71ccba..de8a93dd8b1 100644 --- a/litellm/rust_bridge/ocr.py +++ b/litellm/rust_bridge/ocr.py @@ -2,44 +2,16 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable, Mapping, Sequence +from collections.abc import Awaitable, Mapping from dataclasses import dataclass from types import MappingProxyType from typing import Final, Protocol, cast # noqa: TID251 # native extension exposes dynamically typed callables import httpx -import litellm -from litellm.constants import request_timeout -from litellm.llms.azure_ai.ocr.common_utils import is_azure_cohere_parse_model from litellm.llms.base_llm.ocr.transformation import PROVIDER_NATIVE_RESPONSE_KEY, OCRResponse -from litellm.rust_bridge.bindings import NativeBinding, native_exception_types +from litellm.rust_bridge.bindings import NativeBinding from litellm.rust_bridge.timeouts import timeout_to_seconds as _timeout_to_seconds -from litellm.types.router import GenericLiteLLMParams -from litellm.utils import ProviderConfigManager - -_RUST_OCR_PROVIDERS: Final = frozenset({"mistral", "azure_ai", "vertex_ai"}) -_RUST_OCR_CONFIG_FIELDS: Final = frozenset( - { - "azure_ad_token", - "tenant_id", - "client_id", - "client_secret", - "azure_scope", - "azure_authority_host", - "azure_credential", - "azure_federated_token_file", - "vertex_credentials", - "vertex_ai_credentials", - "vertex_project", - "vertex_ai_project", - "vertex_location", - "vertex_ai_location", - } -) -_RUST_OCR_SECRET_FIELDS: Final = frozenset( - {"azure_ad_token", "client_secret", "azure_federated_token_file", "vertex_credentials", "vertex_ai_credentials"} -) @dataclass(frozen=True, slots=True) @@ -87,26 +59,6 @@ class RustAocr(Protocol): raise NotImplementedError -class _OCRLogging(Protocol): - def update_from_kwargs( - self, - *, - kwargs: dict[str, object], - model: str, - optional_params: dict[str, object], - litellm_params: dict[str, object], - custom_llm_provider: str | None, - ) -> None: ... - - def pre_call( - self, - *, - input: str, - api_key: str | None, - additional_args: dict[str, object], - ) -> None: ... - - def _as_ocr(value: object) -> RustOcr | None: return cast(RustOcr, value) if callable(value) else None @@ -127,204 +79,6 @@ def load_rust_aocr() -> RustAocr | None: return _AOCR.load() -def provider(request: LiteLLMOcrRequest) -> str | None: - if request.custom_llm_provider is not None: - return request.custom_llm_provider - prefix: Final = request.model.partition("/")[0] - if prefix in _RUST_OCR_PROVIDERS: - return prefix - if request.model.startswith("mistral-ocr"): - return "mistral" - return None - - -def supported(request: LiteLLMOcrRequest) -> bool: - request_provider: Final = provider(request) - if request_provider not in _RUST_OCR_PROVIDERS: - return False - if request_provider == "azure_ai": - return ( - not is_azure_cohere_parse_model(request.model) - and not callable(request.kwargs.get("azure_ad_token_provider")) - and request.kwargs.get("azure_username") is None - and request.kwargs.get("azure_password") is None - ) - return True - - -def _optional_params(request: LiteLLMOcrRequest, resolve_secret: Callable[[str], str | None]) -> Mapping[str, object]: - optional_params: Final = MappingProxyType( - { - name: value - for name, value in request.kwargs.items() - if (name not in GenericLiteLLMParams.model_fields or name in _RUST_OCR_CONFIG_FIELDS) - and name not in ("litellm_logging_obj", "aocr", "litellm_call_id", "proxy_server_request") - } - ) - request_provider: Final = provider(request) - if request_provider == "azure_ai" and litellm.enable_azure_ad_token_refresh is True: - return MappingProxyType({**optional_params, "enable_azure_ad_token_refresh": True}) - if request_provider != "vertex_ai": - return optional_params - project: Final = ( - request.kwargs.get("vertex_project") - or request.kwargs.get("vertex_ai_project") - or litellm.vertex_project - or resolve_secret("VERTEXAI_PROJECT") - ) - location: Final = ( - request.kwargs.get("vertex_location") - or request.kwargs.get("vertex_ai_location") - or litellm.vertex_location - or resolve_secret("VERTEXAI_LOCATION") - or resolve_secret("VERTEX_LOCATION") - ) - credentials: Final = ( - request.kwargs.get("vertex_credentials") - or request.kwargs.get("vertex_ai_credentials") - or resolve_secret("VERTEXAI_CREDENTIALS") - ) - vertex_params: Final = MappingProxyType( - { - name: value - for name, value in ( - ("vertex_project", project), - ("vertex_location", location), - ("vertex_credentials", credentials), - ) - if value is not None - } - ) - return MappingProxyType({**optional_params, **vertex_params}) - - -def _input_sources(request: LiteLLMOcrRequest, optional_params: Mapping[str, object]) -> Mapping[str, str]: - proxy_request_value: Final = request.kwargs.get("proxy_server_request") - if not isinstance(proxy_request_value, Mapping): - return MappingProxyType({}) - proxy_request: Final = cast( # cast-ok: runtime Mapping check narrows metadata with unknown key and value types - Mapping[object, object], proxy_request_value - ) - credential_fields_value: Final = proxy_request.get("credential_fields", ()) - credential_fields: Final = ( - frozenset(name for name in credential_fields_value if isinstance(name, str)) - if isinstance(credential_fields_value, (list, tuple, set, frozenset)) - else frozenset() - ) - request_fields_value: Final = proxy_request.get("body_fields") - request_fields: Sequence[object] - if isinstance(request_fields_value, Sequence) and not isinstance(request_fields_value, (str, bytes)): - request_fields = cast( # cast-ok: runtime Sequence check excludes scalar strings and bytes - Sequence[object], request_fields_value - ) - else: - body_value: Final = proxy_request.get("body") - request_fields = ( - tuple(cast(Mapping[object, object], body_value)) # cast-ok: runtime Mapping check establishes iterable keys - if isinstance(body_value, Mapping) - else () - ) - names: Final = frozenset(optional_params) | frozenset({"api_key", "api_base", "extra_headers"}) - request_sources: Final = MappingProxyType( - {name: "request" for name in names if name in request_fields or name in credential_fields} - ) - if litellm.enable_azure_ad_token_refresh is True and "enable_azure_ad_token_refresh" in optional_params: - return MappingProxyType({**request_sources, "enable_azure_ad_token_refresh": "deployment"}) - return request_sources - - -def _marshal( - request: LiteLLMOcrRequest, - resolve_secret: Callable[[str], str | None], - convert_file_document: Callable[[dict[str, object]], dict[str, str]], -) -> LiteLLMOcrRequest: - if not isinstance(request.document, dict): - raise TypeError(f"document must be a dict with 'type' and URL/file field, got {type(request.document)}") - document: Final = ( - convert_file_document(request.document) if request.document.get("type") == "file" else request.document - ) - request_provider: Final = provider(request) - api_key: Final = ( - request.api_key or resolve_secret("MISTRAL_API_KEY") if request_provider == "mistral" else request.api_key - ) - optional_params: Final = _optional_params(request, resolve_secret) - input_sources: Final = _input_sources(request, optional_params) - logged_optional_params: Final = MappingProxyType( - {name: "****" if name in _RUST_OCR_SECRET_FIELDS else value for name, value in optional_params.items()} - ) - logged_kwargs: Final = MappingProxyType( - { - name: "****" if name in _RUST_OCR_SECRET_FIELDS else value - for name, value in request.kwargs.items() - if name != "proxy_server_request" - } - ) - logging_obj: Final = cast( # cast-ok: client decorator injects the logging object through untyped kwargs - _OCRLogging, request.kwargs["litellm_logging_obj"] - ) - logging_obj.update_from_kwargs( - kwargs=dict(logged_kwargs), # mutable-ok: legacy logging mutates its kwargs copy - model=request.model, - optional_params=dict(logged_optional_params), # mutable-ok: legacy logging requires concrete dict params - litellm_params={ # mutable-ok: legacy logging requires a concrete params dict - "litellm_call_id": request.kwargs.get("litellm_call_id"), - "api_base": request.api_base, - }, - custom_llm_provider=request_provider, - ) - logging_obj.pre_call( - input="OCR document processing", - api_key=api_key, - additional_args={ # mutable-ok: pre_call mutates the additional_args dict - "complete_input_dict": { # mutable-ok: callbacks consume a JSON-serializable request dict - "model": request.model, - "document": document, - **logged_optional_params, - }, - "api_base": request.api_base or "", - "headers": request.extra_headers or {}, # mutable-ok: logging callbacks consume a concrete headers dict - }, - ) - return LiteLLMOcrRequest( - model=request.model, - document=document, - api_key=api_key, - api_base=request.api_base, - timeout=request.timeout if request.timeout is not None else request_timeout, - custom_llm_provider=request.custom_llm_provider, - extra_headers=request.extra_headers, - kwargs=optional_params, - input_sources=input_sources, - ) - - -def _map_error(error: Exception, request: LiteLLMOcrRequest) -> Exception: - exception_types: Final = native_exception_types() - if exception_types is None or not isinstance(error, exception_types[1]): - return error - request_provider: Final = provider(request) - if request_provider is None: - return error - provider_config: Final = ProviderConfigManager.get_provider_ocr_config( - model=request.model.removeprefix(f"{request_provider}/"), provider=litellm.LlmProviders(request_provider) - ) - if provider_config is None: - return error - error_args: Final = cast( # cast-ok: BaseException.args exposes Any while native errors carry scalar args - tuple[object, ...], error.args - ) - status: Final = error_args[0] if error_args and isinstance(error_args[0], int) else 500 - message: Final = str(error_args[1]) if len(error_args) > 1 else str(error) - error_factory: Final = cast( # cast-ok: legacy provider error factories have untyped callable parameters - Callable[..., Exception], provider_config.get_error_class - ) - return error_factory( - error_message=message, - status_code=status or 500, - headers={}, # mutable-ok: provider error factories require a concrete headers dict - ) - - def _response(response: Mapping[str, object]) -> OCRResponse: provider_native_response: Final = response.get(PROVIDER_NATIVE_RESPONSE_KEY) normalized: Final = OCRResponse.model_validate( @@ -335,56 +89,6 @@ def _response(response: Mapping[str, object]) -> OCRResponse: return normalized -def run( - request: LiteLLMOcrRequest, - resolve_secret: Callable[[str], str | None], - convert_file_document: Callable[[dict[str, object]], dict[str, str]], -) -> OCRResponse | None: - if load_rust_ocr() is None: - return None - marshalled: Final = _marshal(request, resolve_secret, convert_file_document) - try: - response: Final = ocr( - model=marshalled.model, - document=dict(marshalled.document), # mutable-ok: PyO3 OCR binding requires a concrete dict - api_key=marshalled.api_key, - api_base=marshalled.api_base, - custom_llm_provider=marshalled.custom_llm_provider, - extra_headers=marshalled.extra_headers, - optional_params=dict(marshalled.kwargs), # mutable-ok: PyO3 OCR binding requires a concrete dict - input_sources=marshalled.input_sources, - timeout=marshalled.timeout, - ) - except Exception as error: - raise _map_error(error, request) from error - return _response(response) if response is not None else None - - -async def arun( - request: LiteLLMOcrRequest, - resolve_secret: Callable[[str], str | None], - convert_file_document: Callable[[dict[str, object]], dict[str, str]], -) -> OCRResponse | None: - if load_rust_aocr() is None: - return None - marshalled: Final = _marshal(request, resolve_secret, convert_file_document) - try: - response: Final = await aocr( - model=marshalled.model, - document=dict(marshalled.document), # mutable-ok: PyO3 OCR binding requires a concrete dict - api_key=marshalled.api_key, - api_base=marshalled.api_base, - custom_llm_provider=marshalled.custom_llm_provider, - extra_headers=marshalled.extra_headers, - optional_params=dict(marshalled.kwargs), # mutable-ok: PyO3 OCR binding requires a concrete dict - input_sources=marshalled.input_sources, - timeout=marshalled.timeout, - ) - except Exception as error: - raise _map_error(error, request) from error - return _response(response) if response is not None else None - - def ocr( *, model: str, diff --git a/litellm/rust_bridge/ocr_lifecycle.py b/litellm/rust_bridge/ocr_lifecycle.py new file mode 100644 index 00000000000..5ca584e1c11 --- /dev/null +++ b/litellm/rust_bridge/ocr_lifecycle.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from collections.abc import Awaitable, Mapping, Sequence +from typing import Final, Protocol, cast # noqa: TID251 # validates dynamically loaded native callables + +import litellm +from litellm.llms.base_llm.ocr.transformation import OCRResponse +from litellm.rust_bridge.bindings import NativeBinding +from litellm.rust_bridge.ocr import LiteLLMOcrRequest + + +class NativeOcrLifecycle(Protocol): + def __call__( + self, + request: LiteLLMOcrRequest, + args: Sequence[object], + kwargs: Mapping[str, object], + asynchronous: bool, + ) -> OCRResponse | Awaitable[OCRResponse]: ... + + +class ExceptionMapper(Protocol): + def __call__( + self, + *, + model: str, + custom_llm_provider: str | None, + original_exception: Exception, + completion_kwargs: dict[str, object], + extra_kwargs: dict[str, object], + ) -> Exception: ... + + +def _binding(value: object) -> NativeOcrLifecycle | None: + if not callable(value): + return None + return cast("NativeOcrLifecycle", value) # cast-ok: callable validated at the native binding boundary + + +NATIVE_OCR_LIFECYCLE: Final = NativeBinding("_ocr_lifecycle", validate=_binding) + + +def select(request: LiteLLMOcrRequest) -> NativeOcrLifecycle | None: + if request.kwargs.get("aocr"): + return None + return NATIVE_OCR_LIFECYCLE.load() + + +def arguments(request: LiteLLMOcrRequest) -> Mapping[str, object]: + return request.kwargs + + +def map_failure(error: Exception, request: LiteLLMOcrRequest, request_provider: str) -> Exception: + mapper: Final = cast( # cast-ok: bounded adapter for the legacy public exception mapper + ExceptionMapper, litellm.exception_type + ) + try: + return mapper( + model=request.model.removeprefix(f"{request_provider}/"), + custom_llm_provider=request_provider, + original_exception=error, + completion_kwargs=dict(arguments(request)), # mutable-ok: exception mapper requires owned kwargs + extra_kwargs=dict(request.kwargs), # mutable-ok: exception mapper requires owned kwargs + ) + except Exception as public_error: + public_error.__context__ = error + return public_error diff --git a/litellm/types/guardrails.py b/litellm/types/guardrails.py index 02dee40f2a3..69cb88bfa2f 100644 --- a/litellm/types/guardrails.py +++ b/litellm/types/guardrails.py @@ -137,6 +137,7 @@ class SupportedGuardrailIntegrations(Enum): COMPRESR = "compresr" STRAIKER = "straiker" ALICE = "alice" + CONDUCT = "conduct" class Role(Enum): diff --git a/litellm/types/proxy/discovery_endpoints/agent_skills_endpoints.py b/litellm/types/proxy/discovery_endpoints/agent_skills_endpoints.py new file mode 100644 index 00000000000..0d8bb29e172 --- /dev/null +++ b/litellm/types/proxy/discovery_endpoints/agent_skills_endpoints.py @@ -0,0 +1,25 @@ +"""Agent Skills discovery index, version 0.2.0. + +Schema: https://schemas.agentskills.io/discovery/0.2.0/schema.json +""" + +from typing import Final, Literal + +from pydantic import BaseModel, Field + +AGENT_SKILLS_DISCOVERY_SCHEMA_URL: Final = "https://schemas.agentskills.io/discovery/0.2.0/schema.json" +MAX_SKILL_NAME_LENGTH: Final = 64 +MAX_SKILL_DESCRIPTION_LENGTH: Final = 1024 + + +class AgentSkillsIndexEntry(BaseModel): + name: str + type: Literal["archive"] + description: str + url: str + digest: str + + +class AgentSkillsIndex(BaseModel): + discovery_schema: str = Field(default=AGENT_SKILLS_DISCOVERY_SCHEMA_URL, alias="$schema") + skills: tuple[AgentSkillsIndexEntry, ...] diff --git a/litellm/types/proxy/guardrails/guardrail_hooks/conduct.py b/litellm/types/proxy/guardrails/guardrail_hooks/conduct.py new file mode 100644 index 00000000000..fbff4363351 --- /dev/null +++ b/litellm/types/proxy/guardrails/guardrail_hooks/conduct.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, Field + +from .base import GuardrailConfigModel + + +class ConductGuardrailConfigModelOptionalParams(BaseModel): + workspace_id: str | None = Field( + default=None, + description="Conduct workspace id, sent as the X-Workspace-Id header. Env: CONDUCT_WORKSPACE_ID.", + ) + tool_name: str | None = Field( + default="llm_call", + description="Conduct tool name the prompt is evaluated under. Match the tool your rules target.", + ) + timeout: float | None = Field( + default=8.0, + gt=0.0, + description="Timeout in seconds for the Conduct check.", + ) + unreachable_fallback: Literal["fail_open", "fail_closed"] | None = Field( + default="fail_closed", + description="Behavior when Conduct is unreachable, times out, or rejects the token.", + ) + + +class ConductGuardrailConfigModel(GuardrailConfigModel[ConductGuardrailConfigModelOptionalParams]): + api_key: str = Field( + min_length=1, + description="Conduct agent token. Env: CONDUCT_AGENT_TOKEN.", + ) + api_base: str | None = Field( + default="https://api.conductai.ai", + description="Conduct API base URL. The MCP endpoint is derived as /mcp.", + ) + + @staticmethod + def ui_friendly_name() -> str: + return "Conduct Guard" diff --git a/litellm/types/proxy/management_endpoints/common_daily_activity.py b/litellm/types/proxy/management_endpoints/common_daily_activity.py index 2b39c5dbb9b..090e5c42376 100644 --- a/litellm/types/proxy/management_endpoints/common_daily_activity.py +++ b/litellm/types/proxy/management_endpoints/common_daily_activity.py @@ -43,6 +43,7 @@ class KeyMetadata(BaseModel): key_alias: str | None = None team_id: str | None = None + user_id: str | None = None user_email: str | None = None diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 7fa09951eae..2d8e8071479 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -4497,7 +4497,7 @@ }, "azure/eu/o1-2024-12-17": { "cache_read_input_token_cost": 8.25e-06, - "deprecation_date": "2026-10-21", + "deprecation_date": "2026-11-19", "input_cost_per_token": 1.65e-05, "litellm_provider": "azure", "max_input_tokens": 200000, @@ -4543,7 +4543,7 @@ }, "azure/eu/o3-mini-2025-01-31": { "cache_read_input_token_cost": 6.05e-07, - "deprecation_date": "2026-10-01", + "deprecation_date": "2026-11-19", "input_cost_per_token": 1.21e-06, "input_cost_per_token_batches": 6.05e-07, "litellm_provider": "azure", @@ -8730,7 +8730,7 @@ "supports_function_calling": true }, "azure/o1": { - "deprecation_date": "2026-10-21", + "deprecation_date": "2026-11-19", "cache_read_input_token_cost": 7.5e-06, "input_cost_per_token": 1.5e-05, "litellm_provider": "azure", @@ -8748,7 +8748,7 @@ }, "azure/o1-2024-12-17": { "cache_read_input_token_cost": 7.5e-06, - "deprecation_date": "2026-10-21", + "deprecation_date": "2026-11-19", "input_cost_per_token": 1.5e-05, "litellm_provider": "azure", "max_input_tokens": 200000, @@ -8825,7 +8825,7 @@ "supports_vision": false }, "azure/o3": { - "deprecation_date": "2026-10-21", + "deprecation_date": "2026-11-19", "cache_read_input_token_cost": 5e-07, "input_cost_per_token": 2e-06, "litellm_provider": "azure", @@ -8855,7 +8855,7 @@ "supports_vision": true }, "azure/o3-2025-04-16": { - "deprecation_date": "2026-10-21", + "deprecation_date": "2026-11-19", "cache_read_input_token_cost": 5e-07, "input_cost_per_token": 2e-06, "litellm_provider": "azure", @@ -8886,7 +8886,7 @@ }, "azure/o3-deep-research": { "cache_read_input_token_cost": 2.5e-06, - "deprecation_date": "2026-12-26", + "deprecation_date": "2026-11-19", "input_cost_per_token": 1e-05, "litellm_provider": "azure", "max_input_tokens": 200000, @@ -8923,7 +8923,7 @@ "supports_web_search": true }, "azure/o3-mini": { - "deprecation_date": "2026-10-01", + "deprecation_date": "2026-11-19", "cache_read_input_token_cost": 5.5e-07, "input_cost_per_token": 1.1e-06, "litellm_provider": "azure", @@ -8940,7 +8940,7 @@ }, "azure/o3-mini-2025-01-31": { "cache_read_input_token_cost": 5.5e-07, - "deprecation_date": "2026-10-01", + "deprecation_date": "2026-11-19", "input_cost_per_token": 1.1e-06, "litellm_provider": "azure", "max_input_tokens": 200000, @@ -8954,7 +8954,7 @@ "supports_vision": false }, "azure/o3-pro": { - "deprecation_date": "2026-12-17", + "deprecation_date": "2026-11-19", "input_cost_per_token": 2e-05, "input_cost_per_token_batches": 1e-05, "litellm_provider": "azure", @@ -8985,7 +8985,7 @@ "supports_vision": true }, "azure/o3-pro-2025-06-10": { - "deprecation_date": "2026-12-17", + "deprecation_date": "2026-11-19", "input_cost_per_token": 2e-05, "input_cost_per_token_batches": 1e-05, "litellm_provider": "azure", @@ -9016,7 +9016,7 @@ "supports_vision": true }, "azure/o4-mini": { - "deprecation_date": "2026-10-16", + "deprecation_date": "2026-11-19", "cache_read_input_token_cost": 2.75e-07, "input_cost_per_token": 1.1e-06, "litellm_provider": "azure", @@ -9047,7 +9047,7 @@ }, "azure/o4-mini-2025-04-16": { "cache_read_input_token_cost": 2.75e-07, - "deprecation_date": "2026-10-16", + "deprecation_date": "2026-11-19", "input_cost_per_token": 1.1e-06, "litellm_provider": "azure", "max_input_tokens": 200000, @@ -9600,7 +9600,7 @@ }, "azure/us/o1-2024-12-17": { "cache_read_input_token_cost": 8.25e-06, - "deprecation_date": "2026-10-21", + "deprecation_date": "2026-11-19", "input_cost_per_token": 1.65e-05, "litellm_provider": "azure", "max_input_tokens": 200000, @@ -9645,7 +9645,7 @@ "supports_vision": false }, "azure/us/o3-2025-04-16": { - "deprecation_date": "2026-10-21", + "deprecation_date": "2026-11-19", "cache_read_input_token_cost": 5.5e-07, "input_cost_per_token": 2.2e-06, "litellm_provider": "azure", @@ -9676,7 +9676,7 @@ }, "azure/us/o3-mini-2025-01-31": { "cache_read_input_token_cost": 6.05e-07, - "deprecation_date": "2026-10-01", + "deprecation_date": "2026-11-19", "input_cost_per_token": 1.21e-06, "input_cost_per_token_batches": 6.05e-07, "litellm_provider": "azure", @@ -9693,7 +9693,7 @@ }, "azure/us/o4-mini-2025-04-16": { "cache_read_input_token_cost": 3.1e-07, - "deprecation_date": "2026-10-16", + "deprecation_date": "2026-11-19", "input_cost_per_token": 1.21e-06, "litellm_provider": "azure", "max_input_tokens": 200000, @@ -14615,7 +14615,7 @@ }, "computer-use-preview": { "input_cost_per_token": 3e-06, - "litellm_provider": "azure", + "litellm_provider": "openai", "max_input_tokens": 8192, "max_output_tokens": 1024, "max_tokens": 1024, @@ -14633,12 +14633,14 @@ ], "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_pdf_input": true, "supports_prompt_caching": false, "supports_reasoning": true, - "supports_response_schema": true, + "supports_response_schema": false, "supports_system_messages": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "source": "https://platform.openai.com/docs/models/computer-use-preview" }, "dall-e-2": { "deprecation_date": "2026-05-12", @@ -39019,7 +39021,9 @@ "supports_max_reasoning_effort": true, "supports_tool_choice": true, "supports_vision": true, - "prompt_cache_min_tokens": 4096 + "prompt_cache_min_tokens": 4096, + "supports_response_schema": true, + "source": "https://openrouter.ai/api/v1/models" }, "openrouter/anthropic/claude-sonnet-4.5": { "input_cost_per_image": 0.0048, @@ -39167,18 +39171,20 @@ }, "openrouter/deepseek/deepseek-v3.2": { "input_cost_per_token": 2.69e-07, - "input_cost_per_token_cache_hit": 2.8e-08, + "input_cost_per_token_cache_hit": 1.345e-07, "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": 4e-07, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "supports_response_schema": true, + "source": "https://openrouter.ai/api/v1/models" }, "openrouter/deepseek/deepseek-v3.2-exp": { "input_cost_per_token": 2.7e-07, @@ -43488,6 +43494,7 @@ "supports_tool_choice": true }, "together_ai/openai/gpt-oss-20b": { + "deprecation_date": "2026-09-14", "input_cost_per_token": 5e-08, "litellm_provider": "together_ai", "max_input_tokens": 131072, @@ -43780,6 +43787,7 @@ "source": "https://docs.together.ai/docs/serverless-models" }, "together_ai/google/gemma-4-31B-it": { + "deprecation_date": "2026-09-14", "input_cost_per_token": 3.9e-07, "litellm_provider": "together_ai", "max_input_tokens": 262144, @@ -43794,6 +43802,7 @@ "supports_vision": true }, "together_ai/intfloat/multilingual-e5-large-instruct": { + "deprecation_date": "2026-09-14", "input_cost_per_token": 2e-08, "litellm_provider": "together_ai", "max_input_tokens": 514, @@ -43906,6 +43915,7 @@ "supports_tool_choice": true }, "together_ai/thinkingmachines/Inkling-Small": { + "deprecation_date": "2026-09-14", "cache_read_input_token_cost": 1e-07, "input_cost_per_token": 5e-07, "litellm_provider": "together_ai", @@ -60832,6 +60842,7 @@ "source": "https://docs.together.ai/docs/serverless-models" }, "together_ai/moonshotai/Kimi-K2.6": { + "deprecation_date": "2026-08-19", "input_cost_per_token": 1.2e-06, "output_cost_per_token": 4.5e-06, "cache_read_input_token_cost": 2e-07, @@ -60858,6 +60869,7 @@ "source": "https://api.together.xyz/v1/models" }, "together_ai/zai-org/GLM-5": { + "deprecation_date": "2026-06-22", "input_cost_per_token": 1e-06, "output_cost_per_token": 3.2e-06, "litellm_provider": "together_ai", @@ -60866,6 +60878,7 @@ "source": "https://api.together.xyz/v1/models" }, "together_ai/zai-org/GLM-5.1": { + "deprecation_date": "2026-07-10", "input_cost_per_token": 1.4e-06, "output_cost_per_token": 4.4e-06, "cache_read_input_token_cost": 2.6e-07, @@ -60883,6 +60896,7 @@ "source": "https://api.together.xyz/v1/models" }, "together_ai/Qwen/Qwen3-Coder-Next-FP8": { + "deprecation_date": "2026-05-14", "input_cost_per_token": 5e-07, "output_cost_per_token": 1.2e-06, "litellm_provider": "together_ai", @@ -60891,6 +60905,7 @@ "source": "https://api.together.xyz/v1/models" }, "together_ai/Qwen/Qwen3-VL-32B-Instruct": { + "deprecation_date": "2026-02-25", "input_cost_per_token": 5e-07, "output_cost_per_token": 1.5e-06, "litellm_provider": "together_ai", @@ -60899,6 +60914,7 @@ "source": "https://api.together.xyz/v1/models" }, "together_ai/Qwen/Qwen3-VL-8B-Instruct": { + "deprecation_date": "2026-04-16", "input_cost_per_token": 1.8e-07, "output_cost_per_token": 6.8e-07, "litellm_provider": "together_ai", @@ -60931,6 +60947,7 @@ "source": "https://api.together.xyz/v1/models" }, "together_ai/Qwen/QwQ-32B": { + "deprecation_date": "2025-11-13", "input_cost_per_token": 1.2e-06, "output_cost_per_token": 1.2e-06, "litellm_provider": "together_ai", diff --git a/pyproject.toml b/pyproject.toml index 448451f7f93..62ce4b4fd61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,18 +67,19 @@ proxy = [ "azure-identity>=1.25.2,<2.0", "azure-storage-blob>=12.28.0,<13.0", "mcp>=1.28.1,<2.0", - "litellm-proxy-extras==0.4.96", - "litellm-enterprise==0.1.66", + "litellm-proxy-extras==0.4.97", + "litellm-enterprise==0.1.67", "RestrictedPython>=8.5,<9.0", "rich>=13.9.4,<14.0", "InquirerPy>=0.3.4,<1.0", + "tomlkit>=0.13.3,<1.0", "polars>=1.38.1,<2.0", "soundfile>=0.12.1,<1.0", "pyroscope-io>=0.8.16,<1.0; sys_platform != 'win32'", "expression>=5.6.0,<6.0", ] # Thin client install for the `lite` CLI on developer laptops. The CLI's heavy -# imports are all guarded, so it runs on the base SDK plus just these five, and +# imports are all guarded, so it runs on the base SDK plus these packages, and # none of the server runtime in `proxy` is pulled in. On Linux, # keyring reaches the Secret Service through secretstorage, which brings # cryptography with it. @@ -88,6 +89,7 @@ cli = [ "requests>=2.32.0,<3.0", "InquirerPy>=0.3.4,<1.0", "keyring>=25.6.0,<26.0", + "tomlkit>=0.13.3,<1.0", ] extra_proxy = [ "prisma>=0.11.0,<1.0", diff --git a/scripts/benchmark_ocr_callbacks.py b/scripts/benchmark_ocr_callbacks.py new file mode 100644 index 00000000000..5db182c3d0e --- /dev/null +++ b/scripts/benchmark_ocr_callbacks.py @@ -0,0 +1,289 @@ +#!/usr/bin/env python3 +"""Measure serial sync/async OCR latency through a loopback HTTP provider + +Run each callback mode in a fresh process against an installed release wheel: +python -I scripts/benchmark_ocr_callbacks.py --callbacks none --label before \ + --expected-transport rust --iterations 200 --warmup 20 --output before-none.json +Repeat with --callbacks noop and with the candidate wheel in a separate venv +""" + +from __future__ import annotations + +import argparse +import asyncio +import base64 +import hashlib +import importlib.metadata +import json +import statistics +import sys +import threading +import time +from collections.abc import Sequence +from dataclasses import asdict, dataclass +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Final, cast + +SIZES: Final = ( + 1024, + 4 * 1024, + 16 * 1024, + 64 * 1024, + 256 * 1024, + 1024 * 1024, +) +MODEL: Final = "mistral/mistral-ocr-latest" +EXPECTED_MARKDOWN: Final = "mock remote OCR response" +RESPONSE: Final = json.dumps( + { + "pages": [{"index": 0, "markdown": EXPECTED_MARKDOWN, "images": [], "dimensions": None}], + "model": "mistral-ocr-latest", + "usage_info": {"pages_processed": 1}, + }, + separators=(",", ":"), +).encode() + + +class Server(ThreadingHTTPServer): + daemon_threads = True + + def __init__(self) -> None: + super().__init__(("127.0.0.1", 0), Handler) + self.user_agents: set[str] = set() + + +class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def do_POST(self) -> None: + server: Final = cast(Server, self.server) + server.user_agents.add(self.headers.get("User-Agent", "")) + length: Final = int(self.headers["Content-Length"]) + body: Final = self.rfile.read(length) + request: Final = json.loads(body) + if self.path != "/v1/ocr" or request.get("model") != "mistral-ocr-latest": + self.send_error(400) + return + document: Final = request.get("document", {}) + if not isinstance(document, dict) or not str(document.get("document_url", "")).startswith( + "data:application/pdf;base64," + ): + self.send_error(400) + return + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(RESPONSE))) + self.end_headers() + self.wfile.write(RESPONSE) + + def log_message(self, format: str, *args: object) -> None: + return + + +@dataclass(frozen=True, slots=True) +class Result: + label: str + mode: str + size: int + iterations: int + median_ms: float + mean_ms: float + p95_ms: float + requests_per_second: float + + +def document(size: int) -> dict[str, str]: + payload: Final = b"%PDF-1.4\n" + b"x" * max(0, size - 9) + encoded: Final = base64.b64encode(payload[:size]).decode("ascii") + return {"type": "document_url", "document_url": f"data:application/pdf;base64,{encoded}"} + + +def percentile(values: Sequence[float], quantile: float) -> float: + ordered: Final = sorted(values) + index: Final = min(len(ordered) - 1, round((len(ordered) - 1) * quantile)) + return ordered[index] + + +def verify(response: object) -> None: + pages: Final = getattr(response, "pages", ()) + if len(pages) != 1 or getattr(pages[0], "markdown", None) != EXPECTED_MARKDOWN: + raise RuntimeError(f"unexpected OCR response: {response!r}") + + +def summarize(label: str, mode: str, size: int, samples: Sequence[float]) -> Result: + median: Final = statistics.median(samples) + return Result( + label=label, + mode=mode, + size=size, + iterations=len(samples), + median_ms=median * 1000, + mean_ms=statistics.fmean(samples) * 1000, + p95_ms=percentile(samples, 0.95) * 1000, + requests_per_second=1 / median, + ) + + +def sync_samples(litellm: object, url: str, request_document: dict[str, str], count: int) -> tuple[float, ...]: + samples: list[float] = [] + for _ in range(count): + started: Final = time.perf_counter() + response: Final = litellm.ocr( + model=MODEL, document=request_document, api_base=url, api_key="mock-key", timeout=30 + ) + samples.append(time.perf_counter() - started) + verify(response) + return tuple(samples) + + +async def async_samples(litellm: object, url: str, request_document: dict[str, str], count: int) -> tuple[float, ...]: + samples: list[float] = [] + for _ in range(count): + started: Final = time.perf_counter() + response: Final = await litellm.aocr( + model=MODEL, document=request_document, api_base=url, api_key="mock-key", timeout=30 + ) + samples.append(time.perf_counter() - started) + verify(response) + return tuple(samples) + + +async def main() -> int: + parser: Final = argparse.ArgumentParser(description="E2E OCR benchmark against a local remote-style HTTP server") + parser.add_argument("--callbacks", choices=("none", "noop"), required=True) + parser.add_argument("--label", required=True) + parser.add_argument("--expected-transport", choices=("python", "rust"), required=True) + parser.add_argument("--iterations", type=int, default=30) + parser.add_argument("--warmup", type=int, default=5) + parser.add_argument("--sizes", type=int, nargs="+", default=SIZES) + parser.add_argument("--output", type=Path, required=True) + args: Final = parser.parse_args() + + import litellm + from litellm.integrations.custom_logger import CustomLogger + + class NoopCallback(CustomLogger): + def __init__(self) -> None: + super().__init__() + self.pre_calls = 0 + self.sync_successes = 0 + self.async_successes = 0 + + def log_pre_api_call(self, model, messages, kwargs): + self.pre_calls += 1 + + def log_success_event(self, kwargs, response_obj, start_time, end_time): + self.sync_successes += 1 + + async def async_log_success_event(self, kwargs, response_obj, start_time, end_time): + self.async_successes += 1 + + registry_names: Final = ( + "callbacks", + "input_callback", + "success_callback", + "failure_callback", + "_async_input_callback", + "_async_success_callback", + "_async_failure_callback", + ) + if any(getattr(litellm, name) for name in registry_names): + raise RuntimeError("benchmark requires initially empty callback registrations") + callback: Final = NoopCallback() + if args.callbacks == "noop": + litellm.callbacks.append(callback) + + rust_toggle: Final = getattr(litellm, "rust", None) + if callable(rust_toggle): + rust_toggle(False) + package: Final = Path(litellm.__file__).resolve() + version: Final = importlib.metadata.version("litellm") + native_path: str | None = None + native_sha256: str | None = None + try: + from litellm.rust_bridge import _native + + native: Final = Path(_native.__file__).resolve() + native_path = str(native) + native_sha256 = hashlib.file_digest(native.open("rb"), "sha256").hexdigest() + except ImportError: + pass + + server: Final = Server() + thread: Final = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + url: Final = f"http://127.0.0.1:{server.server_port}" + results: list[Result] = [] + try: + for size in args.sizes: + request_document: Final = document(size) + sync_samples(litellm, url, request_document, args.warmup) + sync_result: Final = summarize( + args.label, "sync", size, sync_samples(litellm, url, request_document, args.iterations) + ) + results.append(sync_result) + await async_samples(litellm, url, request_document, args.warmup) + async_result: Final = summarize( + args.label, + "async", + size, + await async_samples(litellm, url, request_document, args.iterations), + ) + results.append(async_result) + sys.stdout.write(json.dumps(asdict(sync_result)) + "\n") + sys.stdout.write(json.dumps(asdict(async_result)) + "\n") + sys.stdout.flush() + finally: + server.shutdown() + server.server_close() + thread.join() + + from litellm.litellm_core_utils.litellm_logging import executor + from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER + + await GLOBAL_LOGGING_WORKER.flush() + await asyncio.to_thread(executor.shutdown, wait=True) + per_mode: Final = len(args.sizes) * (args.iterations + args.warmup) + if args.callbacks == "noop": + if (callback.pre_calls, callback.sync_successes, callback.async_successes) != ( + 2 * per_mode, + per_mode, + per_mode, + ): + raise RuntimeError(f"callback delivery mismatch: {vars(callback)}") + elif any(getattr(litellm, name) for name in registry_names): + raise RuntimeError("callback registrations appeared in the no-callback case") + await GLOBAL_LOGGING_WORKER.stop() + + user_agents: Final = tuple(sorted(server.user_agents)) + python_transport: Final = any( + value.startswith("python-httpx") or value.startswith("litellm/") for value in user_agents + ) + if (args.expected_transport == "python") != python_transport: + raise RuntimeError(f"unexpected transport for {args.label}: user_agents={user_agents}") + artifact: Final = { + "label": args.label, + "callbacks": args.callbacks, + "python": sys.executable, + "callback_counts": { + "pre": callback.pre_calls, + "sync_success": callback.sync_successes, + "async_success": callback.async_successes, + }, + "version": version, + "package": str(package), + "native": native_path, + "native_sha256": native_sha256, + "user_agents": user_agents, + "results": tuple(asdict(result) for result in results), + } + args.output.write_text(json.dumps(artifact, indent=2) + "\n") + sys.stdout.write(json.dumps({key: artifact[key] for key in ("label", "version", "package", "user_agents")}) + "\n") + sys.stdout.write(f"results={args.output}\n") + sys.stdout.flush() + return 0 + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main())) diff --git a/terraform/provider/CHANGELOG.md b/terraform/provider/CHANGELOG.md index e5e0a164a83..8c0ef5a8b15 100644 --- a/terraform/provider/CHANGELOG.md +++ b/terraform/provider/CHANGELOG.md @@ -37,6 +37,7 @@ longer signal it. ### Fixed +- **key**: An update that changes `team_id` and fails because the key was already cascade-deleted along with its previous team now recovers by recreating the key under the new team, instead of aborting the apply. The key's absence is confirmed against the proxy first, so an unrelated failure still errors out, and a `team_id` change between two teams that both still exist stays a plain in-place update - **team**: Read now decodes the `team_info` envelope `/team/info` actually returns, so team attributes refresh from the proxy instead of always falling back to the prior state - **key**: Read now unwraps the `info` envelope `/key/info` actually returns; previously reads mapped nothing back into state, so drift on a key was never detected - **key**: Read now picks up `model_rpm_limit`, `model_tpm_limit`, `guardrails`, `tags`, `enforced_params`, `allowed_passthrough_routes`, `rpm_limit_type`, `tpm_limit_type` and `prompts` from `info.metadata`, where the proxy actually stores them; previously they stayed empty in state, so a matching config showed a permanent phantom diff on them and out-of-band changes to them were never detected diff --git a/terraform/provider/litellm/resource_key.go b/terraform/provider/litellm/resource_key.go index 018d01f75a8..39546d588df 100644 --- a/terraform/provider/litellm/resource_key.go +++ b/terraform/provider/litellm/resource_key.go @@ -3,6 +3,7 @@ package litellm import ( "context" "encoding/json" + "errors" "fmt" "log" @@ -321,19 +322,42 @@ func resourceKeyUpdate(ctx context.Context, d *schema.ResourceData, m interface{ metadata, err := plannedKeyMetadata(c, d) if err != nil { - d.Partial(true) - return diag.FromErr(fmt.Errorf("error updating key: %s", err)) + return failedKeyUpdate(ctx, d, m, err) } key.Metadata = metadata if _, err := c.UpdateKey(key); err != nil { - d.Partial(true) - return diag.FromErr(fmt.Errorf("error updating key: %s", err)) + return failedKeyUpdate(ctx, d, m, err) } return resourceKeyRead(ctx, d, m) } +// Deleting a team cascade-deletes its keys, so an apply that moves a key onto a +// replacement team can find the key already gone, and recreating it is the only +// way forward. Confirming it is really gone keeps an unrelated 404 (a rejected +// project_id, say) a hard failure rather than silently orphaning a live key. +func failedKeyUpdate(ctx context.Context, d *schema.ResourceData, m interface{}, err error) diag.Diagnostics { + c := m.(*Client) + if d.HasChange("team_id") && keyIsGone(c, d.Id(), err) { + log.Printf("[WARN] Key %q no longer exists, most likely cascade-deleted with its previous team; recreating it under the new team_id", d.Id()) + return resourceKeyCreate(ctx, d, m) + } + d.Partial(true) + return diag.FromErr(fmt.Errorf("error updating key: %s", err)) +} + +func keyIsGone(c *Client, keyID string, err error) bool { + if errors.Is(err, errKeyGone) { + return true + } + if !isNotFound(err) { + return false + } + key, getErr := c.GetKey(keyID) + return getErr == nil && key == nil +} + func changedMap(d *schema.ResourceData, name string) map[string]interface{} { if !d.HasChange(name) { return nil @@ -341,6 +365,8 @@ func changedMap(d *schema.ResourceData, name string) map[string]interface{} { return d.Get(name).(map[string]interface{}) } +var errKeyGone = errors.New("no longer exists") + func plannedKeyMetadata(c *Client, d *schema.ResourceData) (map[string]interface{}, error) { if !d.HasChange("metadata") { return nil, nil @@ -350,7 +376,7 @@ func plannedKeyMetadata(c *Client, d *schema.ResourceData) (map[string]interface return nil, err } if current == nil { - return nil, fmt.Errorf("key %s no longer exists", d.Id()) + return nil, fmt.Errorf("key %s %w", d.Id(), errKeyGone) } oldDeclared, newDeclared := d.GetChange("metadata") return mergeKeyMetadata(current.Metadata, oldDeclared.(map[string]interface{}), newDeclared.(map[string]interface{})), nil diff --git a/terraform/provider/litellm/resource_key_test.go b/terraform/provider/litellm/resource_key_test.go index 66291eadcc5..fe708edd3d3 100644 --- a/terraform/provider/litellm/resource_key_test.go +++ b/terraform/provider/litellm/resource_key_test.go @@ -7,8 +7,10 @@ import ( "net/http" "net/http/httptest" "reflect" + "sync/atomic" "testing" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" ) @@ -686,3 +688,191 @@ func TestKeyUpdateOmitsUnchangedDuration(t *testing.T) { t.Errorf("update payload unexpectedly contains duration = %v", v) } } + +// newKeyUpdateResourceData builds a *schema.ResourceData reflecting a real +// state -> config diff for team_id (unlike schema.TestResourceDataRaw, which +// has no notion of prior state), so d.HasChange("team_id") behaves the way it +// does during a real Update call. +func newKeyUpdateResourceData(t *testing.T, id, oldTeamID, newTeamID string) *schema.ResourceData { + t.Helper() + state := &terraform.InstanceState{ID: id, Attributes: map[string]string{"team_id": oldTeamID}} + diff := &terraform.InstanceDiff{Attributes: map[string]*terraform.ResourceAttrDiff{ + "team_id": {Old: oldTeamID, New: newTeamID}, + }} + d, err := schema.InternalMap(resourceKey().Schema).Data(state, diff) + if err != nil { + t.Fatalf("building ResourceData returned error: %v", err) + } + return d +} + +// keyRecoveryProxy fakes the two responses the cascade-delete recovery path +// turns on: what POST /key/update returns, and whether GET /key/info still +// finds the key afterwards. +type keyRecoveryProxy struct { + updateStatus int + updateBody string + staleKeyGone bool + updateCalls int32 + generateCalls int32 +} + +const keyNotFoundBody = `{"error":{"message":"Key not found.","type":"not_found_error","param":"key","code":"404"}}` + +func (p *keyRecoveryProxy) handler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/key/update": + atomic.AddInt32(&p.updateCalls, 1) + w.WriteHeader(p.updateStatus) + io.WriteString(w, p.updateBody) + case "/key/generate": + atomic.AddInt32(&p.generateCalls, 1) + io.WriteString(w, `{"key": "sk-new", "token_id": "new-token"}`) + case "/key/info": + requested := r.URL.Query().Get("key") + if p.staleKeyGone && requested != "new-token" { + w.WriteHeader(http.StatusNotFound) + io.WriteString(w, keyNotFoundBody) + return + } + json.NewEncoder(w).Encode(map[string]interface{}{ + "key": requested, + "info": map[string]interface{}{"team_id": "team-b"}, + }) + default: + http.NotFound(w, r) + } + } +} + +func runKeyUpdate(t *testing.T, p *keyRecoveryProxy, d *schema.ResourceData) diag.Diagnostics { + t.Helper() + srv := httptest.NewServer(p.handler()) + defer srv.Close() + return resourceKeyUpdate(context.Background(), d, NewClient(srv.URL, "test-key", true)) +} + +// Reassigning a key between two teams that both still exist is a plain +// in-place /key/update and must not be turned into a destroy/recreate. +func TestResourceKeyUpdateTeamReassignmentStaysInPlace(t *testing.T) { + proxy := &keyRecoveryProxy{updateStatus: http.StatusOK, updateBody: `{"key": "hash-1"}`} + d := newKeyUpdateResourceData(t, "hash-1", "team-a", "team-b") + + if diags := runKeyUpdate(t, proxy, d); diags.HasError() { + t.Fatalf("update returned error: %v", diags) + } + if got := atomic.LoadInt32(&proxy.generateCalls); got != 0 { + t.Errorf("a benign team reassignment must not recreate the key, got %d /key/generate calls", got) + } + if d.Id() != "hash-1" { + t.Errorf("Id = %q, want hash-1 unchanged", d.Id()) + } +} + +// The reported bug: the key was cascade-deleted along with its old team, so +// /key/update 404s and the apply must recover by recreating it. +func TestResourceKeyUpdateRecreatesCascadeDeletedKey(t *testing.T) { + proxy := &keyRecoveryProxy{updateStatus: http.StatusNotFound, updateBody: keyNotFoundBody, staleKeyGone: true} + d := newKeyUpdateResourceData(t, "stale-token", "team-a", "team-b") + + if diags := runKeyUpdate(t, proxy, d); diags.HasError() { + t.Fatalf("a cascade-deleted key must be recreated, not error: %v", diags) + } + if got := atomic.LoadInt32(&proxy.updateCalls); got != 1 { + t.Errorf("expected 1 /key/update attempt before recovering, got %d", got) + } + if got := atomic.LoadInt32(&proxy.generateCalls); got != 1 { + t.Errorf("expected exactly 1 /key/generate recreate, got %d", got) + } + if d.Id() != "new-token" { + t.Errorf("Id = %q, want the recreated key's new-token", d.Id()) + } +} + +// /key/update 404s for reasons other than a missing key, a rejected +// project_id among them. Recovering on the status code alone would orphan a +// key that is still live on the proxy, so the key's absence must be confirmed. +func TestResourceKeyUpdateNotFoundWithLiveKeyFailsLoudly(t *testing.T) { + proxy := &keyRecoveryProxy{ + updateStatus: http.StatusNotFound, + updateBody: `{"error":{"message":"Project not found, project_id=proj-1"}}`, + } + d := newKeyUpdateResourceData(t, "hash-1", "team-a", "team-b") + + if diags := runKeyUpdate(t, proxy, d); !diags.HasError() { + t.Fatal("a 404 on a key that still exists must stay an error") + } + if got := atomic.LoadInt32(&proxy.generateCalls); got != 0 { + t.Errorf("expected no recreate while the key is still live, got %d /key/generate calls", got) + } + if d.Id() != "hash-1" { + t.Errorf("Id = %q, want hash-1 untouched on a hard failure", d.Id()) + } +} + +// A key gone for some reason unrelated to a team move still fails loudly. +func TestResourceKeyUpdateNotFoundWithoutTeamChangeFailsLoudly(t *testing.T) { + proxy := &keyRecoveryProxy{updateStatus: http.StatusNotFound, updateBody: keyNotFoundBody, staleKeyGone: true} + d := newKeyUpdateResourceData(t, "gone-token", "team-a", "team-a") + + if diags := runKeyUpdate(t, proxy, d); !diags.HasError() { + t.Fatal("expected an error when team_id did not change") + } + if got := atomic.LoadInt32(&proxy.generateCalls); got != 0 { + t.Errorf("expected no recreate when team_id is unchanged, got %d /key/generate calls", got) + } + if d.Id() != "gone-token" { + t.Errorf("Id = %q, want gone-token untouched on a hard failure", d.Id()) + } +} + +// A transient failure must never be mistaken for a cascade-deleted key. +func TestResourceKeyUpdateServerErrorDoesNotRecreate(t *testing.T) { + proxy := &keyRecoveryProxy{ + updateStatus: http.StatusInternalServerError, + updateBody: `{"error":{"message":"Internal Server Error"}}`, + staleKeyGone: true, + } + d := newKeyUpdateResourceData(t, "hash-1", "team-a", "team-b") + + if diags := runKeyUpdate(t, proxy, d); !diags.HasError() { + t.Fatal("expected a 500 to surface as an error") + } + if got := atomic.LoadInt32(&proxy.generateCalls); got != 0 { + t.Errorf("expected no recreate for a transient error, got %d /key/generate calls", got) + } +} + +// The metadata pre-read fails before /key/update is ever reached when the key +// is gone, so that path needs the same recovery. +func TestResourceKeyUpdateRecreatesCascadeDeletedKeyWithMetadataChange(t *testing.T) { + proxy := &keyRecoveryProxy{updateStatus: http.StatusOK, updateBody: `{"key": "hash-1"}`, staleKeyGone: true} + state := &terraform.InstanceState{ID: "stale-token", Attributes: map[string]string{ + "team_id": "team-a", + "metadata.%": "1", + "metadata.tier": "gold", + }} + diff := &terraform.InstanceDiff{Attributes: map[string]*terraform.ResourceAttrDiff{ + "team_id": {Old: "team-a", New: "team-b"}, + "metadata.tier": {Old: "gold", New: "silver"}, + }} + d, err := schema.InternalMap(resourceKey().Schema).Data(state, diff) + if err != nil { + t.Fatalf("building ResourceData returned error: %v", err) + } + + if diags := runKeyUpdate(t, proxy, d); diags.HasError() { + t.Fatalf("a cascade-deleted key must be recreated, not error: %v", diags) + } + if got := atomic.LoadInt32(&proxy.updateCalls); got != 0 { + t.Errorf("expected the metadata pre-read to short-circuit /key/update, got %d calls", got) + } + if got := atomic.LoadInt32(&proxy.generateCalls); got != 1 { + t.Errorf("expected exactly 1 /key/generate recreate, got %d", got) + } + if d.Id() != "new-token" { + t.Errorf("Id = %q, want the recreated key's new-token", d.Id()) + } +} diff --git a/tests/e2e/management/test_key_management_e2e.py b/tests/e2e/management/test_key_management_e2e.py index 8b7d5f0eb6f..353b0f7cf09 100644 --- a/tests/e2e/management/test_key_management_e2e.py +++ b/tests/e2e/management/test_key_management_e2e.py @@ -12,8 +12,8 @@ asserting once. from __future__ import annotations import time -from collections.abc import Callable -from typing import Literal +from collections.abc import Callable, Iterator +from typing import Final, Literal import pytest @@ -21,8 +21,11 @@ from e2e_config import unique_marker from e2e_http import NoBody, StreamingResponse, unwrap from lifecycle import ResourceManager from management_client import ManagementClient -from models import KeyDeleteBody, KeyGenerateBody, KeyUpdateBody -from pydantic import BaseModel +from models import ( + CLEAR, ChatResponse, KeyDeleteBody, KeyGenerateBody, KeyInfo, KeyUpdateBody, + LiteLLMParamsBody, OrgNewBody, TeamNewBody, +) +from pydantic import BaseModel, RootModel pytestmark = pytest.mark.e2e @@ -131,7 +134,93 @@ def _unblock(client: ManagementClient, key: str) -> None: ) +class ProjectIdentity(BaseModel): + project_id: str + + +class ProjectCreateBody(BaseModel): + team_id: str + project_alias: str + models: list[str] + + +class ProjectBlockBody(ProjectIdentity): + blocked: bool + + +class ProjectDeleteBody(BaseModel): + project_ids: list[str] + + +@pytest.fixture +def project_resources(client: ManagementClient) -> Iterator[ResourceManager]: + manager: Final = ResourceManager(client=client.proxy, strict_cleanup=True) + yield manager + manager.teardown() + + class TestKeyManagementRoutes: + @pytest.mark.covers("mgmt.key.update.persists") + def test_project_detachment_preserves_key_scope_and_refreshes_auth( + self, client: ManagementClient, project_resources: ResourceManager + ) -> None: + resources: Final = project_resources + name: Final = f"e2e-detach-{unique_marker()}" + model_id: Final = client.proxy.create_model( + name, LiteLLMParamsBody(model="openai/synthetic-detachment", api_key="synthetic", mock_response="orbit") + ) + resources.defer(lambda: client.proxy.delete_model(model_id)) + org_id: Final = client.create_org(OrgNewBody(organization_alias=name, models=[name])) + resources.defer(lambda: client.delete_org(org_id)) + team_id: Final = client.create_team(TeamNewBody(team_alias=name, organization_id=org_id, models=[name])) + resources.defer(lambda: client.delete_team(team_id)) + project: Final = unwrap(client.proxy.transport.post( + "/project/new", headers=client.proxy.transport.master, + json=ProjectCreateBody(team_id=team_id, project_alias=name, models=[name]), + response_type=ProjectIdentity, + )) + resources.defer(lambda: unwrap(client.proxy.transport.delete( + "/project/delete", headers=client.proxy.transport.master, + json=ProjectDeleteBody(project_ids=[project.project_id]), response_type=RootModel[list[ProjectIdentity]], + ))) + key: Final = _generate_key(client, resources, KeyGenerateBody( + key_alias=name, team_id=team_id, organization_id=org_id, project_id=project.project_id, + models=[name], max_budget=5, tpm_limit=12345, rpm_limit=97, + )) + initial: Final = client.chat_status(key, name, "project attached") + assert initial.ok, initial.body + _ = unwrap(client.update_key(KeyUpdateBody(key=key, key_alias=f"{name}-saved"))) + assert client.proxy.key_info(key).project_id == project.project_id + _ = unwrap(client.update_key(KeyUpdateBody(key=key, project_id=project.project_id))) + rejected: Final = client.proxy.transport.send( + "/key/update", headers=client.proxy.transport.master, + json=KeyUpdateBody(key=key, project_id=f"{name}-different"), + ) + assert rejected.status_code == 400 and "reassignment" in rejected.body + assert client.proxy.key_info(key).project_id == project.project_id + _ = unwrap(client.proxy.transport.post( + "/project/update", headers=client.proxy.transport.master, + json=ProjectBlockBody(project_id=project.project_id, blocked=True), response_type=NoBody, + )) + blocked: Final = client.chat_status(key, name, "project blocked") + assert not blocked.ok and "is blocked" in blocked.body + detached: Final = unwrap(client.proxy.transport.post( + "/key/update", headers=client.proxy.transport.master, + json=KeyUpdateBody(key=key, project_id=CLEAR), response_type=KeyInfo, + )) + assert detached.project_id is None + saved: Final = client.proxy.key_info(key) + assert (saved.project_id, saved.team_id, saved.organization_id) == (None, team_id, org_id) + assert (saved.models, saved.max_budget, saved.tpm_limit, saved.rpm_limit) == ([name], 5, 12345, 97) + allowed: Final = client.chat_status(key, name, "project detached") + assert allowed.ok, allowed.body + message: Final = ChatResponse.model_validate_json(allowed.body).choices[0].message + assert message is not None and message.content == "orbit" + _ = unwrap(client.update_key(KeyUpdateBody(key=key, project_id=CLEAR))) + assert client.proxy.key_info(key).project_id is None + denied: Final = client.chat_status(key, f"{name}-outside", "outside key scope") + assert denied.status_code in (401, 403), denied.body + @pytest.mark.covers("mgmt.key.info.persists") def test_info_reflects_the_fields_the_key_was_created_with( self, client: ManagementClient, resources: ResourceManager diff --git a/tests/e2e/models.py b/tests/e2e/models.py index f362d4cc6e5..3cab0334dea 100644 --- a/tests/e2e/models.py +++ b/tests/e2e/models.py @@ -76,6 +76,7 @@ class KeyGenerateBody(BaseModel): budget_duration: str | None = None user_id: str | None = None team_id: str | None = None + project_id: str | None = None organization_id: str | None = None budget_id: str | None = None key_alias: str | None = None @@ -139,6 +140,8 @@ class KeyInfo(BaseModel): models: list[str] = [] tpm_limit: int | None = None rpm_limit: int | None = None + project_id: str | None = None + organization_id: str | None = None team_id: str | None = None blocked: bool | None = None spend: float | None = None @@ -1057,6 +1060,7 @@ class KeyUpdateBody(BaseModel): clears `budget_reset_at` with it), and `metadata` replaces the stored metadata wholesale.""" key: str + project_id: str | Cleared | None = None models: list[str] | None = None key_alias: str | None = None tpm_limit: int | None = None diff --git a/tests/e2e/provider_edge.py b/tests/e2e/provider_edge.py index ceb695ffcd6..6c87c7ef7ac 100644 --- a/tests/e2e/provider_edge.py +++ b/tests/e2e/provider_edge.py @@ -45,18 +45,16 @@ import hashlib import re import threading from collections import deque -from collections.abc import Mapping, Sequence -from contextlib import closing +from collections.abc import Generator, Mapping, Sequence +from contextlib import closing, contextmanager from dataclasses import dataclass, field from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from itertools import islice from pathlib import Path from types import MappingProxyType -from typing import Final, Generator, Literal, assert_never +from typing import Final, Literal, assert_never from urllib.parse import parse_qsl, urlsplit -from pydantic import JsonValue, TypeAdapter - from e2e_http import ( NetworkError, StreamChunk, @@ -94,6 +92,7 @@ from fixture_mode import ( current_test_key, parse_fixture_mode, ) +from pydantic import JsonValue, TypeAdapter EDGE_MOUNTS: Final[Mapping[str, str]] = MappingProxyType( { @@ -495,7 +494,29 @@ class ReplayEdge: source: ReplaySource -type EdgeBackend = RecordEdge | ReplayEdge +@dataclass(frozen=True, slots=True) +class LiveEdge: + pass + + +type EdgeBackend = RecordEdge | ReplayEdge | LiveEdge + + +@dataclass(slots=True) +class ProviderRequestObservation: + marker: str + _count: int = field(default=0, init=False) + _lock: threading.Lock = field(default_factory=threading.Lock, init=False) + + def observe(self, body: bytes | None) -> None: + if body is not None and self.marker.encode() in body: + with self._lock: + self._count += 1 + + @property + def count(self) -> int: + with self._lock: + return self._count @dataclass(frozen=True, slots=True) @@ -721,6 +742,24 @@ def _handle_record( assert_never(head) +def _handle_live( + method: str, url: str, headers: Mapping[str, str], body: bytes | None, timeout: float +) -> EdgeOutcome: + forwarded: Final = { + name: value for name, value in headers.items() if name.lower() not in _REQUEST_DROPPED_HEADERS + } + head: Final = forward_stream(method, url, headers=forwarded, body=body, timeout=timeout) + match head: + case NetworkError(message=message): + return _recorded_outcome(_network_error_response(message)) + case StreamHead() if _is_streamed(head.headers): + return EdgeStream(head.status_code, _filtered_response_headers(head.headers), head.steps) + case StreamHead(): + return _recorded_outcome(_drain_to_response(head)) + case _: + assert_never(head) + + def _handle_replay(source: ReplaySource, request: RecordedRequest) -> EdgeOutcome: try: interaction: Final = source.next_interaction(request) @@ -753,6 +792,10 @@ def handle_edge_request( method, split.path, split.query, body, _header_value(headers, "content-type") ) match backend: + case LiveEdge(): + return _handle_live( + method, _upstream_url(upstream_base, upstream_path, split.query), headers, body, timeout + ) case RecordEdge(): return _handle_record( backend, @@ -792,6 +835,8 @@ class _EdgeHandler(BaseHTTPRequestHandler): assert isinstance(edge_server, _EdgeHTTPServer) length: Final = int(self.headers.get("content-length") or "0") body: Final = self.rfile.read(length) if length else None + if edge_server.observation is not None: + edge_server.observation.observe(body) outcome: Final = handle_edge_request( edge_server.backend, edge_server.mounts, @@ -857,11 +902,13 @@ class _EdgeHTTPServer(ThreadingHTTPServer): backend: EdgeBackend, mounts: Mapping[str, str], forward_timeout: float, + observation: ProviderRequestObservation | None, ) -> None: super().__init__(bind, _EdgeHandler) self.backend: Final = backend self.mounts: Final = mounts self.forward_timeout: Final = forward_timeout + self.observation: Final = observation @dataclass(frozen=True, slots=True) @@ -890,13 +937,14 @@ def start_provider_edge( bind_host: str = "127.0.0.1", advertise_host: str | None = None, forward_timeout: float = 60.0, + observation: ProviderRequestObservation | None = None, ) -> RunningEdge: """Boot an edge server on an OS-assigned port in a daemon thread. ``advertise_host`` is what api_base URLs name (it differs from the bind host when the proxy runs in a container and reaches the host machine via a gateway address like host.docker.internal).""" server: Final = _EdgeHTTPServer( - (bind_host, 0), backend=backend, mounts=mounts, forward_timeout=forward_timeout + (bind_host, 0), backend=backend, mounts=mounts, forward_timeout=forward_timeout, observation=observation ) thread: Final = threading.Thread(target=server.serve_forever, name="e2e-provider-edge", daemon=True) thread.start() @@ -979,3 +1027,40 @@ def provider_edge_api_base( return _shared_edge(mode, bundle_dir, bind_host, advertise_host, forward_timeout).api_base(mount) case _: assert_never(mode) + + +def _observed_backend(mode_raw: str, bundle_dir: Path) -> EdgeBackend: + mode: Final = parse_fixture_mode(mode_raw) + match mode: + case InvalidFixtureMode(value=value): + raise ValueError(f"E2E_FIXTURE_MODE={value!r} is not one of {', '.join(FIXTURE_MODES)}") + case "live": + return LiveEdge() + case "record": + return RecordEdge(_shared_recorder(bundle_dir), threading.Lock()) + case "replay": + return ReplayEdge(_shared_replay_source(bundle_dir)) + case _: + assert_never(mode) + + +@contextmanager +def observed_provider_edge( + observation: ProviderRequestObservation, + *, + mode_raw: str, + bundle_dir: Path, + bind_host: str, + advertise_host: str, + forward_timeout: float = 60.0, + mounts: Mapping[str, str] = EDGE_MOUNTS, +) -> Generator[ProviderEdge, None, None]: + running: Final = start_provider_edge( + _observed_backend(mode_raw, bundle_dir), mounts=mounts, + bind_host=bind_host, advertise_host=advertise_host, + forward_timeout=forward_timeout, observation=observation, + ) + try: + yield running.edge + finally: + running.shutdown() diff --git a/tests/e2e/router/test_reliability_cache_e2e.py b/tests/e2e/router/test_reliability_cache_e2e.py index 4ea05a1ecca..f7a2f2ffeb7 100644 --- a/tests/e2e/router/test_reliability_cache_e2e.py +++ b/tests/e2e/router/test_reliability_cache_e2e.py @@ -1,37 +1,98 @@ -"""Live e2e: the response cache returns a cached answer on an exact repeat. +"""An exact cache hit preserves the full choices and usage without another provider call. -The same unique prompt is sent twice to the real `gpt-5.5` deployment under the -same key: the first call is a cache miss (the proxy computes and stores the entry, -and returns no x-litellm-cache-key), the second is an exact hit (the proxy serves -from cache and returns x-litellm-cache-key). This relies on the standard Redis -response cache being enabled on the proxy under test. +Response IDs, creation timestamps and proxy headers are transport metadata; +compare every field within choices and usage, including provider extensions. """ from __future__ import annotations +from typing import Final + import pytest - from complexity_router_client import ComplexityRouterClient -from e2e_config import unique_marker -from reliability_support import chat_override +from e2e_config import ( + FIXTURE_DIR, + FIXTURE_MODE_RAW, + PROVIDER_EDGE_ADVERTISE_HOST, + PROVIDER_EDGE_BIND_HOST, + REQUEST_TIMEOUT, + unique_marker, +) +from lifecycle import ResourceManager +from models import ChatBody, ChatMessage, ChatResponse, LiteLLMParamsBody +from provider_edge import ProviderRequestObservation, observed_provider_edge +from pydantic import BaseModel, JsonValue -pytestmark = pytest.mark.e2e +pytestmark = [pytest.mark.e2e, pytest.mark.replayable] + + +class _CacheChatBody(ChatBody): + ttl: int = 600 + + +class _CachedAnswer(BaseModel): + model: str + choices: tuple[dict[str, JsonValue], ...] + usage: dict[str, JsonValue] class TestReliabilityCache: @pytest.mark.covers("reliability.cache.exact.returns_cached") - def test_exact_cache_returns_cached(self, client: ComplexityRouterClient, scoped_key: str) -> None: - prompt = f"cache probe {unique_marker()}" + def test_exact_cache_returns_cached( + self, client: ComplexityRouterClient, resources: ResourceManager, scoped_key: str + ) -> None: + marker: Final = unique_marker() + model: Final = f"e2e-cache-{marker}" + prompt: Final = f"Reply with a short sentence about a blue lantern. Request marker: {marker}" + observation: Final = ProviderRequestObservation(marker) - first = chat_override(client.proxy, scoped_key, "gpt-5.5", prompt, cache=None) - assert first.status_code == 200, f"first call should succeed, got {first.status_code}: {first.body[:300]}" - assert "x-litellm-cache-key" not in first.headers, ( - "first (uncached) call must not report a cache-key header" - ) + with observed_provider_edge( + observation, + mode_raw=FIXTURE_MODE_RAW, + bundle_dir=FIXTURE_DIR, + bind_host=PROVIDER_EDGE_BIND_HOST, + advertise_host=PROVIDER_EDGE_ADVERTISE_HOST, + forward_timeout=REQUEST_TIMEOUT, + ) as edge: + model_id: Final = client.proxy.create_model( + model, + LiteLLMParamsBody( + model="openai/gpt-5.6", + api_key="os.environ/OPENAI_API_KEY", + api_base=f"{edge.api_base('openai')}/v1", + ), + ) + resources.defer(lambda: client.proxy.delete_model(model_id)) + body: Final = _CacheChatBody( + model=model, + messages=[ChatMessage(role="user", content=prompt)], + max_completion_tokens=512, + reasoning_effort="none", + cache=None, + ) + first: Final = client.proxy.transport.send( + "/chat/completions", headers=client.proxy.transport.bearer(scoped_key), json=body + ) + assert first.status_code == 200, f"first call should succeed, got {first.status_code}: {first.body[:300]}" + assert "x-litellm-cache-key" not in first.headers, "first call must be a cache miss" + answer: Final = ChatResponse.model_validate_json(first.body) + assert len(answer.choices) == 1 + choice: Final = answer.choices[0] + assert choice.message is not None and choice.message.role == "assistant" + assert choice.message.content is not None and choice.message.content.strip(), "first answer is empty" + assert choice.finish_reason == "stop" + assert answer.usage is not None + assert answer.usage.prompt_tokens is not None and answer.usage.prompt_tokens > 0 + assert answer.usage.completion_tokens is not None and answer.usage.completion_tokens > 0 + assert answer.usage.total_tokens == answer.usage.prompt_tokens + answer.usage.completion_tokens + assert observation.count == 1, "first miss must invoke the provider exactly once" - second = chat_override(client.proxy, scoped_key, "gpt-5.5", prompt, cache=None) - assert second.status_code == 200, f"second call should succeed, got {second.status_code}: {second.body[:300]}" - assert "x-litellm-cache-key" in second.headers, ( - "second identical call should hit the response cache and report a cache-key header " - "(requires the proxy's Redis response cache to be enabled)" - ) + second: Final = client.proxy.transport.send( + "/chat/completions", headers=client.proxy.transport.bearer(scoped_key), json=body + ) + assert second.status_code == 200, f"second call should succeed, got {second.status_code}: {second.body[:300]}" + assert second.headers.get("x-litellm-cache-key"), "identical request must hit the response cache" + assert _CachedAnswer.model_validate_json(second.body) == _CachedAnswer.model_validate_json(first.body), ( + "cache hit changed the answer, finish reason or usage" + ) + assert observation.count == 1, "two successful requests must invoke the provider exactly once" diff --git a/tests/e2e/test_provider_edge.py b/tests/e2e/test_provider_edge.py index 8ab389ee43c..18f72ac0e7a 100644 --- a/tests/e2e/test_provider_edge.py +++ b/tests/e2e/test_provider_edge.py @@ -34,10 +34,7 @@ from pathlib import Path from typing import Final import pytest -from pydantic import TypeAdapter - from e2e_http import RawResponse, StreamChunk, forward -from fixture_canonical import canonicalize from fixture_bundle import ( BundleRecorder, Interaction, @@ -49,6 +46,7 @@ from fixture_bundle import ( prepare_bundle, slug_for_test, ) +from fixture_canonical import canonicalize from fixture_mode import current_test_key from provider_edge import ( REPLAY_MISS_STATUS, @@ -56,15 +54,18 @@ from provider_edge import ( EdgeReply, EdgeStream, ProviderEdge, + ProviderRequestObservation, RecordEdge, ReplayEdge, ReplaySource, edge_request, handle_edge_request, + observed_provider_edge, provider_edge_api_base, replay_leftover_error, start_provider_edge, ) +from pydantic import TypeAdapter CHAT_PATH = "/openai/v1/chat/completions" UPLOAD_PATH = "/openai/v1/files" @@ -1290,3 +1291,62 @@ class TestApiBaseSeam: assert second.endswith("/anthropic") assert first.rsplit("/", 1)[0] == second.rsplit("/", 1)[0] assert (root / "manifest.json").is_file() + + +class TestProviderRequestObservation: + def test_live_counts_repeated_marker_calls_without_recording(self, tmp_path: Path) -> None: + observation: Final = ProviderRequestObservation("observed-lantern") + with fake_provider() as provider: + with observed_provider_edge( + observation, mode_raw="live", bundle_dir=tmp_path / "unused", + bind_host="127.0.0.1", advertise_host="127.0.0.1", + mounts={"openai": provider_url(provider)}, + ) as edge: + assert observation.count == 0 + unrelated: Final = call_edge(edge, "POST", CHAT_PATH, body=chat_body("other-lantern")) + assert unrelated.status_code == 200 + assert observation.count == 0 + first: Final = call_edge(edge, "POST", CHAT_PATH, body=chat_body("observed-lantern")) + assert first.status_code == 200 + assert json_object(first.body)["echo"] == chat_body("observed-lantern").decode() + assert observation.count == 1 + second: Final = call_edge(edge, "POST", CHAT_PATH, body=chat_body("observed-lantern")) + assert second.status_code == 200 + assert observation.count == 2 + assert len(provider.hits) == 3 + assert not (tmp_path / "unused").exists() + + def test_record_and_replay_count_each_matching_call(self, tmp_path: Path) -> None: + with fake_provider() as provider: + for mode, observation in ( + ("record", ProviderRequestObservation("observed-lantern")), + ("replay", ProviderRequestObservation("observed-lantern")), + ): + with observed_provider_edge( + observation, mode_raw=mode, bundle_dir=tmp_path / "bundle", + bind_host="127.0.0.1", advertise_host="127.0.0.1", + mounts={"openai": provider_url(provider)}, + ) as edge: + assert observation.count == 0 + for expected, response in ( + (index, call_edge(edge, "POST", CHAT_PATH, body=chat_body("observed-lantern"))) + for index in (1, 2) + ): + assert response.status_code == 200 + assert json_object(response.body)["hit"] == expected + assert observation.count == expected + assert len(provider.hits) == 2 + assert replay_leftover_error( + mode_raw="replay", bundle_dir=tmp_path / "bundle", test_key=current_test_key() + ) is None + + def test_failed_provider_attempt_is_counted(self, tmp_path: Path) -> None: + observation: Final = ProviderRequestObservation("observed-lantern") + with observed_provider_edge( + observation, mode_raw="live", bundle_dir=tmp_path / "unused", + bind_host="127.0.0.1", advertise_host="127.0.0.1", + mounts={"openai": "http://127.0.0.1:9"}, + ) as edge: + response: Final = call_edge(edge, "POST", CHAT_PATH, body=chat_body("observed-lantern")) + assert response.status_code == 502 + assert observation.count == 1 diff --git a/tests/e2e/test_proxy_client.py b/tests/e2e/test_proxy_client.py index 3b84a47e3cc..1b0133f12cb 100644 --- a/tests/e2e/test_proxy_client.py +++ b/tests/e2e/test_proxy_client.py @@ -245,6 +245,7 @@ class TestReplicasFor: replica_urls=("http://gateway-1", "http://gateway-2"), ) assert set(client.replicas_for("/key/info")) == {"http://backend"} + assert set(client.replicas_for("/project/info")) == {"http://backend"} assert set(client.replicas_for("/v1/models")) == {"http://gateway-1", "http://gateway-2"} def test_monolith_reads_management_routes_back_from_every_replica(self) -> None: diff --git a/tests/e2e/transport.py b/tests/e2e/transport.py index 44fdbaa3e41..e8caa801467 100644 --- a/tests/e2e/transport.py +++ b/tests/e2e/transport.py @@ -295,6 +295,7 @@ CONTROL_PLANE_PREFIXES: tuple[str, ...] = ( "/user", "/team", "/organization", + "/project", "/customer", "/end_user", "/tag", diff --git a/tests/enterprise/litellm_enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py b/tests/enterprise/litellm_enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py index c23b203feba..36878fa698c 100644 --- a/tests/enterprise/litellm_enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py +++ b/tests/enterprise/litellm_enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py @@ -1292,6 +1292,21 @@ async def test_update_project_leaves_metadata_untouched_when_no_limit_is_sent(mo assert "metadata" not in _written_project_data(mock_prisma) +@pytest.mark.asyncio +async def test_update_project_clears_only_the_explicit_budget_cap(monkeypatch): + mock_prisma = _project_update_mocks(monkeypatch, {}) + mock_prisma.db.litellm_projecttable.find_unique.return_value.budget_id = "budget-clear-test" + mock_prisma.db.litellm_budgettable.update = mock.AsyncMock() + + await _run_project_update("project-clear-test", max_budget=None) + + mock_prisma.db.litellm_budgettable.update.assert_awaited_once_with( + where={"budget_id": "budget-clear-test"}, + data={"max_budget": None, "updated_by": "1234"}, + ) + assert "max_budget" not in _written_project_data(mock_prisma) + + @pytest.mark.parametrize("entry", ["all-proxy-models", "*", "azure/*"]) def test_enforce_project_model_quota_rejects_entries_that_expand_at_request_time(entry): """A quota keyed on a wildcard entry is never applied by the limiter, so it fails loudly.""" diff --git a/tests/proxy_migration_tests/test_prisma_toolchain.py b/tests/proxy_migration_tests/test_prisma_toolchain.py index 0ed33193a9b..4e2274cc582 100644 --- a/tests/proxy_migration_tests/test_prisma_toolchain.py +++ b/tests/proxy_migration_tests/test_prisma_toolchain.py @@ -37,7 +37,10 @@ from litellm_proxy_extras.prisma_toolchain import ( node_binary_path, prisma_bootstrap_timeout, prisma_command_timeout, + prisma_cli_available, prisma_migrate_deploy_timeout, + resolve_prisma_argv, + run_prisma, ) from litellm_proxy_extras.utils import ProxyExtrasDBManager @@ -401,3 +404,95 @@ def test_every_prisma_command_timeout_is_overridable(module: str) -> None: f"{module} still hardcodes a Prisma timeout at lines {literals}; " "route it through prisma_command_timeout() so it can be raised without a release" ) + + +FAKE_PRISMA_MODULE_MAIN = """import json +import sys + +print(json.dumps({"module_argv": sys.argv[1:]})) +""" + + +def _write_fake_prisma_module(tmp_path: Path) -> Path: + package_dir = tmp_path / "fakemodule" / "prisma" + package_dir.mkdir(parents=True) + (package_dir / "__init__.py").write_text("") + (package_dir / "__main__.py").write_text(FAKE_PRISMA_MODULE_MAIN) + return package_dir.parent + + +def _empty_bin(tmp_path: Path) -> Path: + bin_dir = tmp_path / "emptybin" + bin_dir.mkdir() + return bin_dir + + +def test_run_prisma_uses_the_module_when_the_console_script_is_not_on_path( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + empty_bin = _empty_bin(tmp_path) + module_root = _write_fake_prisma_module(tmp_path) + monkeypatch.setenv("PATH", str(empty_bin)) + + result = run_prisma( + ["prisma", "migrate", "deploy"], + timeout=60, + env={"PATH": str(empty_bin), "PYTHONPATH": str(module_root)}, + ) + + assert json.loads(result.stdout) == {"module_argv": ["migrate", "deploy"]} + + +def test_run_prisma_prefers_the_console_script_on_path( + toolchain_env: tuple[Path, Path], tmp_path: Path +) -> None: + _, log_path = toolchain_env + module_root = _write_fake_prisma_module(tmp_path) + + result = run_prisma( + ["prisma", "--version"], + timeout=60, + env={**os.environ, "PYTHONPATH": str(module_root)}, + ) + + assert [call["args"] for call in _fake_prisma_calls(log_path)] == [["--version"]] + assert "module_argv" not in result.stdout + + +def test_resolve_prisma_argv_leaves_an_explicit_cli_path_alone( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("PATH", str(_empty_bin(tmp_path))) + explicit = ("/app/.cache/prisma-python/prisma", "migrate", "deploy") + + assert resolve_prisma_argv(explicit) == explicit + + +def test_prisma_cli_is_unavailable_with_neither_script_nor_package( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("PATH", str(_empty_bin(tmp_path))) + monkeypatch.delitem(sys.modules, "prisma", raising=False) + monkeypatch.setattr(sys, "path", []) + + assert prisma_cli_available() is False + + +def test_prisma_cli_is_available_through_the_package_alone( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("PATH", str(_empty_bin(tmp_path))) + monkeypatch.delitem(sys.modules, "prisma", raising=False) + monkeypatch.setattr(sys, "path", [str(_write_fake_prisma_module(tmp_path))]) + + assert prisma_cli_available() is True + + +def test_prisma_cli_is_available_through_the_console_script_alone( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("PATH", str(_write_fake_prisma(tmp_path))) + monkeypatch.delitem(sys.modules, "prisma", raising=False) + monkeypatch.setattr(sys, "path", []) + + assert prisma_cli_available() is True diff --git a/tests/rust-python-harness/strategies/trace_parity/gateway/execution.py b/tests/rust-python-harness/strategies/trace_parity/gateway/execution.py index 2bd3a50f39f..860e872dd44 100644 --- a/tests/rust-python-harness/strategies/trace_parity/gateway/execution.py +++ b/tests/rust-python-harness/strategies/trace_parity/gateway/execution.py @@ -1,7 +1,8 @@ from __future__ import annotations -import asyncio -from collections.abc import Awaitable, Callable +import json +import subprocess +from functools import cache from pathlib import Path from typing import Final, Protocol, cast @@ -28,12 +29,12 @@ class _GatewayClient(Protocol): def _collect_python(fixture: RouteFixture) -> tuple[FunctionTraceEvent, ...]: - import litellm from fastapi.testclient import TestClient + import litellm + from litellm.proxy import proxy_server from litellm.proxy._types import UserAPIKeyAuth from litellm.proxy.anthropic_endpoints.endpoints import user_api_key_auth - from litellm.proxy import proxy_server provider_model: Final = cast(str, fixture.kwargs["provider_model"]) model_alias: Final = cast(str, fixture.kwargs["model_alias"]) @@ -76,24 +77,24 @@ def _collect_python(fixture: RouteFixture) -> tuple[FunctionTraceEvent, ...]: def _collect_rust(fixture: RouteFixture) -> tuple[FunctionTraceEvent, ...]: - from litellm.rust_bridge import get_native_bridge - - bridge: Final[object | None] = get_native_bridge() - trace: Final[object | None] = getattr(bridge, "_trace", None) if bridge is not None else None - gateway_messages: Final[object | None] = getattr(trace, "gateway_messages", None) - if gateway_messages is None or not callable(gateway_messages): - raise RuntimeError("native Rust trace bridge does not expose gateway_messages") - invoke_gateway: Final = cast(Callable[[str, str, str, object], Awaitable[object]], gateway_messages) - - async def invoke() -> object: - return await invoke_gateway( - cast(str, fixture.kwargs["model_alias"]), - cast(str, fixture.kwargs["provider_model"]), - cast(str, fixture.kwargs["api_base"]), - fixture.kwargs["body"], - ) - - result: Final = asyncio.run(invoke()) + payload: Final = json.dumps( + { + "model_alias": fixture.kwargs["model_alias"], + "provider_model": fixture.kwargs["provider_model"], + "api_base": fixture.kwargs["api_base"], + "body": fixture.kwargs["body"], + } + ) + completed: Final = subprocess.run( + (_gateway_trace_binary(),), + input=payload, + capture_output=True, + text=True, + check=False, + ) + if completed.returncode != 0: + raise RuntimeError(f"Rust gateway trace failed: {completed.stderr.strip()}") + result: Final = json.loads(completed.stdout) payload: Final = TraceResponsePayload.model_validate(result) response: Final = _GatewayResponsePayload.model_validate(payload.response) if response.status != 200: @@ -101,6 +102,34 @@ def _collect_rust(fixture: RouteFixture) -> tuple[FunctionTraceEvent, ...]: return native_trace_events(payload) +@cache +def _gateway_trace_binary() -> Path: + repo_root: Final = next(parent for parent in Path(__file__).resolve().parents if (parent / "litellm-rust").is_dir()) + rust_root: Final = repo_root / "litellm-rust" + completed: Final = subprocess.run( + ( + "cargo", + "build", + "--quiet", + "--package", + "litellm-ai-gateway", + "--features", + "trace-parity", + "--bin", + "trace-parity-gateway", + "--target-dir", + rust_root / "target", + ), + cwd=rust_root, + capture_output=True, + text=True, + check=False, + ) + if completed.returncode != 0: + raise RuntimeError(f"Rust gateway trace build failed: {completed.stderr.strip()}") + return rust_root / "target" / "debug" / "trace-parity-gateway" + + def _collect(scenario: TraceScenario, engine: Engine) -> tuple[FunctionTraceEvent, ...] | TraceExecutionFailure: try: with replay_server() as provider: diff --git a/tests/test_litellm/caching/test_redis_cache.py b/tests/test_litellm/caching/test_redis_cache.py index bb42495db87..348494921ac 100644 --- a/tests/test_litellm/caching/test_redis_cache.py +++ b/tests/test_litellm/caching/test_redis_cache.py @@ -1287,6 +1287,67 @@ async def test_redis_cache_async_increment_forwards_ttl_exactly( assert result == 0.75 assert spy.eval_calls[0][4] == expected_ttl_arg + +@pytest.mark.asyncio +async def test_pool_wait_timeout_is_a_timeout_failure_not_hard_connectivity(): + """A saturated blocking pool must not open the breaker before the timeout minimum duration. + + redis-py's async BlockingConnectionPool gives up waiting for a free connection by raising + ConnectionError("No connection available.") chained from asyncio.TimeoutError. Redis itself + is healthy in that case, so the failure has to be classed as a timeout and stay behind the + duration gate instead of being counted as a hard connectivity failure. + """ + from fakeredis import FakeServer + from fakeredis.aioredis import FakeConnection + from redis.asyncio import BlockingConnectionPool, Redis + from redis.exceptions import ConnectionError as RedisConnectionError + + from litellm.caching.redis_cache import RedisCircuitBreaker, _run_under_circuit_breaker + + pool = BlockingConnectionPool(connection_class=FakeConnection, server=FakeServer(), max_connections=1, timeout=0.01) + client = Redis(connection_pool=pool) + breaker = RedisCircuitBreaker(failure_threshold=3, recovery_timeout=60, timeout_min_duration=5.0) + + busy_connection = await pool.get_connection() + try: + for _ in range(breaker.failure_threshold * 2): + with pytest.raises(RedisConnectionError, match="No connection available"): + await _run_under_circuit_breaker(breaker, "op", lambda: client.get("k")) + finally: + await pool.release(busy_connection) + + assert breaker.is_open() is False, "a busy pool is a timeout gated on duration, not a dead Redis" + assert await _run_under_circuit_breaker(breaker, "op", lambda: client.get("k")) is None + await client.aclose() + + +def test_timeout_classification_follows_the_explicit_cause_chain_only(): + from redis.exceptions import ConnectionError as RedisConnectionError + + from litellm.caching.redis_cache import _is_redis_timeout_failure + + def raise_chained_from_timeout() -> None: + try: + raise asyncio.TimeoutError() + except asyncio.TimeoutError as err: + raise RedisConnectionError("No connection available.") from err + + def raise_while_handling_timeout() -> None: + try: + raise asyncio.TimeoutError() + except asyncio.TimeoutError: + raise RedisConnectionError("refused") + + with pytest.raises(RedisConnectionError) as chained: + raise_chained_from_timeout() + with pytest.raises(RedisConnectionError) as contextual: + raise_while_handling_timeout() + + assert _is_redis_timeout_failure(chained.value) is True + assert _is_redis_timeout_failure(contextual.value) is False + assert _is_redis_timeout_failure(RedisConnectionError("refused")) is False + + class _RoundTripCountingRedis: """Fake redis.asyncio client: one round trip per awaited command or pipeline execute.""" diff --git a/tests/test_litellm/caching/test_redis_semantic_cache.py b/tests/test_litellm/caching/test_redis_semantic_cache.py index df990c43530..9884e9d9bc0 100644 --- a/tests/test_litellm/caching/test_redis_semantic_cache.py +++ b/tests/test_litellm/caching/test_redis_semantic_cache.py @@ -1,3 +1,5 @@ +from collections.abc import Iterator +from contextlib import contextmanager from importlib import import_module import sys from unittest.mock import AsyncMock, MagicMock, patch @@ -5,18 +7,19 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +@contextmanager +def _fake_redisvl_modules(semantic_cache_mock: MagicMock, custom_vectorizer_mock: MagicMock) -> Iterator[None]: + with pytest.MonkeyPatch.context() as mp: + mp.setitem(sys.modules, "redisvl.extensions.llmcache", MagicMock(SemanticCache=semantic_cache_mock)) + mp.setitem(sys.modules, "redisvl.utils.vectorize", MagicMock(CustomTextVectorizer=custom_vectorizer_mock)) + yield + # Tests for RedisSemanticCache def test_redis_semantic_cache_initialization(monkeypatch): # Mock the redisvl import semantic_cache_mock = MagicMock() - with patch.dict( - "sys.modules", - { - "redisvl.extensions.llmcache": MagicMock(SemanticCache=semantic_cache_mock), - "redisvl.utils.vectorize": MagicMock(CustomTextVectorizer=MagicMock()), - }, - ): + with _fake_redisvl_modules(semantic_cache_mock, MagicMock()): from litellm.caching.redis_semantic_cache import RedisSemanticCache # Set environment variables @@ -44,15 +47,7 @@ def test_redis_semantic_cache_get_cache(monkeypatch): semantic_cache_mock = MagicMock() custom_vectorizer_mock = MagicMock() - with patch.dict( - "sys.modules", - { - "redisvl.extensions.llmcache": MagicMock(SemanticCache=semantic_cache_mock), - "redisvl.utils.vectorize": MagicMock( - CustomTextVectorizer=custom_vectorizer_mock - ), - }, - ): + with _fake_redisvl_modules(semantic_cache_mock, custom_vectorizer_mock): from litellm.caching.redis_semantic_cache import RedisSemanticCache # Set environment variables @@ -110,15 +105,7 @@ def test_redis_semantic_cache_rejects_unscoped_cache_hit(monkeypatch): semantic_cache_mock = MagicMock() custom_vectorizer_mock = MagicMock() - with patch.dict( - "sys.modules", - { - "redisvl.extensions.llmcache": MagicMock(SemanticCache=semantic_cache_mock), - "redisvl.utils.vectorize": MagicMock( - CustomTextVectorizer=custom_vectorizer_mock - ), - }, - ): + with _fake_redisvl_modules(semantic_cache_mock, custom_vectorizer_mock): from litellm.caching.redis_semantic_cache import RedisSemanticCache monkeypatch.setenv("REDIS_HOST", "localhost") @@ -162,15 +149,7 @@ def test_redis_semantic_cache_set_cache_stores_cache_key_filter(monkeypatch): semantic_cache_mock = MagicMock() custom_vectorizer_mock = MagicMock() - with patch.dict( - "sys.modules", - { - "redisvl.extensions.llmcache": MagicMock(SemanticCache=semantic_cache_mock), - "redisvl.utils.vectorize": MagicMock( - CustomTextVectorizer=custom_vectorizer_mock - ), - }, - ): + with _fake_redisvl_modules(semantic_cache_mock, custom_vectorizer_mock): from litellm.caching.redis_semantic_cache import RedisSemanticCache monkeypatch.setenv("REDIS_HOST", "localhost") @@ -210,15 +189,7 @@ def test_redis_semantic_cache_uses_isolated_index_for_old_schema(monkeypatch): ) custom_vectorizer_mock = MagicMock() - with patch.dict( - "sys.modules", - { - "redisvl.extensions.llmcache": MagicMock(SemanticCache=semantic_cache_mock), - "redisvl.utils.vectorize": MagicMock( - CustomTextVectorizer=custom_vectorizer_mock - ), - }, - ): + with _fake_redisvl_modules(semantic_cache_mock, custom_vectorizer_mock): from litellm.caching.redis_semantic_cache import RedisSemanticCache monkeypatch.setenv("REDIS_HOST", "localhost") @@ -252,15 +223,7 @@ def test_redis_semantic_cache_overwrites_stale_isolated_index(monkeypatch): ) custom_vectorizer_mock = MagicMock() - with patch.dict( - "sys.modules", - { - "redisvl.extensions.llmcache": MagicMock(SemanticCache=semantic_cache_mock), - "redisvl.utils.vectorize": MagicMock( - CustomTextVectorizer=custom_vectorizer_mock - ), - }, - ): + with _fake_redisvl_modules(semantic_cache_mock, custom_vectorizer_mock): from litellm.caching.redis_semantic_cache import RedisSemanticCache monkeypatch.setenv("REDIS_HOST", "localhost") @@ -292,15 +255,7 @@ def test_redis_semantic_cache_reraises_unexpected_isolated_index_error(monkeypat ) custom_vectorizer_mock = MagicMock() - with patch.dict( - "sys.modules", - { - "redisvl.extensions.llmcache": MagicMock(SemanticCache=semantic_cache_mock), - "redisvl.utils.vectorize": MagicMock( - CustomTextVectorizer=custom_vectorizer_mock - ), - }, - ): + with _fake_redisvl_modules(semantic_cache_mock, custom_vectorizer_mock): from litellm.caching.redis_semantic_cache import RedisSemanticCache monkeypatch.setenv("REDIS_HOST", "localhost") @@ -369,15 +324,15 @@ def test_redis_semantic_cache_builds_filter_expression(monkeypatch): def __eq__(self, value): return (self.field_name, value) - with patch.dict("sys.modules", {"redisvl.query.filter": MagicMock(Tag=FakeTag)}): - from litellm.caching.redis_semantic_cache import RedisSemanticCache + monkeypatch.setitem(sys.modules, "redisvl.query.filter", MagicMock(Tag=FakeTag)) + from litellm.caching.redis_semantic_cache import RedisSemanticCache - redis_semantic_cache = RedisSemanticCache.__new__(RedisSemanticCache) + redis_semantic_cache = RedisSemanticCache.__new__(RedisSemanticCache) - assert redis_semantic_cache._get_cache_key_filter_expression("test_key") == ( - RedisSemanticCache.CACHE_KEY_FIELD_NAME, - "test_key", - ) + assert redis_semantic_cache._get_cache_key_filter_expression("test_key") == ( + RedisSemanticCache.CACHE_KEY_FIELD_NAME, + "test_key", + ) @pytest.mark.asyncio @@ -386,15 +341,7 @@ async def test_redis_semantic_cache_async_get_cache(monkeypatch): semantic_cache_mock = MagicMock() custom_vectorizer_mock = MagicMock() - with patch.dict( - "sys.modules", - { - "redisvl.extensions.llmcache": MagicMock(SemanticCache=semantic_cache_mock), - "redisvl.utils.vectorize": MagicMock( - CustomTextVectorizer=custom_vectorizer_mock - ), - }, - ): + with _fake_redisvl_modules(semantic_cache_mock, custom_vectorizer_mock): from litellm.caching.redis_semantic_cache import RedisSemanticCache # Set environment variables @@ -449,15 +396,7 @@ async def test_redis_semantic_cache_async_get_cache_rejects_unscoped_hit(monkeyp semantic_cache_mock = MagicMock() custom_vectorizer_mock = MagicMock() - with patch.dict( - "sys.modules", - { - "redisvl.extensions.llmcache": MagicMock(SemanticCache=semantic_cache_mock), - "redisvl.utils.vectorize": MagicMock( - CustomTextVectorizer=custom_vectorizer_mock - ), - }, - ): + with _fake_redisvl_modules(semantic_cache_mock, custom_vectorizer_mock): from litellm.caching.redis_semantic_cache import RedisSemanticCache monkeypatch.setenv("REDIS_HOST", "localhost") @@ -499,15 +438,7 @@ async def test_redis_semantic_cache_async_set_cache_stores_cache_key_filter( semantic_cache_mock = MagicMock() custom_vectorizer_mock = MagicMock() - with patch.dict( - "sys.modules", - { - "redisvl.extensions.llmcache": MagicMock(SemanticCache=semantic_cache_mock), - "redisvl.utils.vectorize": MagicMock( - CustomTextVectorizer=custom_vectorizer_mock - ), - }, - ): + with _fake_redisvl_modules(semantic_cache_mock, custom_vectorizer_mock): from litellm.caching.redis_semantic_cache import RedisSemanticCache monkeypatch.setenv("REDIS_HOST", "localhost") @@ -1255,15 +1186,7 @@ def test_redis_init_defers_redisvl_construction(monkeypatch): semantic_cache_mock = MagicMock() custom_vectorizer_mock = MagicMock() - with patch.dict( - "sys.modules", - { - "redisvl.extensions.llmcache": MagicMock(SemanticCache=semantic_cache_mock), - "redisvl.utils.vectorize": MagicMock( - CustomTextVectorizer=custom_vectorizer_mock - ), - }, - ): + with _fake_redisvl_modules(semantic_cache_mock, custom_vectorizer_mock): from litellm.caching.redis_semantic_cache import RedisSemanticCache monkeypatch.setenv("REDIS_HOST", "localhost") @@ -1291,15 +1214,7 @@ def test_redis_failed_llmcache_build_is_not_memoized(monkeypatch): ) custom_vectorizer_mock = MagicMock() - with patch.dict( - "sys.modules", - { - "redisvl.extensions.llmcache": MagicMock(SemanticCache=semantic_cache_mock), - "redisvl.utils.vectorize": MagicMock( - CustomTextVectorizer=custom_vectorizer_mock - ), - }, - ): + with _fake_redisvl_modules(semantic_cache_mock, custom_vectorizer_mock): from litellm.caching.redis_semantic_cache import RedisSemanticCache monkeypatch.setenv("REDIS_HOST", "localhost") diff --git a/tests/test_litellm/integrations/gcs_bucket/test_gcs_bucket_base.py b/tests/test_litellm/integrations/gcs_bucket/test_gcs_bucket_base.py index 8d662311da1..a458752bed0 100644 --- a/tests/test_litellm/integrations/gcs_bucket/test_gcs_bucket_base.py +++ b/tests/test_litellm/integrations/gcs_bucket/test_gcs_bucket_base.py @@ -128,3 +128,20 @@ class TestGCSBucketBase: assert object_name.endswith("-target_uploadType_media") assert ".." not in object_name assert "?" not in object_name + + +class TestGCSBucketLoggerBucketName: + @pytest.mark.asyncio + async def test_the_bucket_name_it_is_constructed_with_survives(self, monkeypatch): + """Reading config.yaml out of a GCS bucket asks for that bucket, not the logging one (LIT-6982).""" + monkeypatch.setenv("GCS_BUCKET_NAME", "logging-bucket") + monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", True) + + assert GCSBucketLogger(bucket_name="config-bucket").BUCKET_NAME == "config-bucket" + + @pytest.mark.asyncio + async def test_no_bucket_name_still_falls_back_to_the_environment(self, monkeypatch): + monkeypatch.setenv("GCS_BUCKET_NAME", "logging-bucket") + monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", True) + + assert GCSBucketLogger().BUCKET_NAME == "logging-bucket" diff --git a/tests/test_litellm/integrations/test_custom_guardrail.py b/tests/test_litellm/integrations/test_custom_guardrail.py index bb4822eae57..2fd5fa76d8e 100644 --- a/tests/test_litellm/integrations/test_custom_guardrail.py +++ b/tests/test_litellm/integrations/test_custom_guardrail.py @@ -3016,3 +3016,62 @@ class TestPreCallHookResponseIsNotLoggedVerbatim: ) assert self._logged_response(data) == "mask" + + @pytest.mark.asyncio + async def test_pre_call_hook_adding_tools_logs_mask(self): + class ToolInjectingGuardrail(CustomGuardrail): + @log_guardrail_information + async def async_pre_call_hook( + self, + user_api_key_dict: UserAPIKeyAuth, + cache: object, + data: dict[str, object], + call_type: str, + ) -> dict[str, object]: + return {**data, "tools": [{"type": "function", "function": {"name": "guardrail_injected_tool"}}]} + + data = self._request() + await ToolInjectingGuardrail(guardrail_name="g").async_pre_call_hook( + user_api_key_dict=UserAPIKeyAuth(), cache=None, data=data, call_type="acompletion" + ) + + assert self._logged_response(data) == "mask" + + @pytest.mark.asyncio + async def test_apply_guardrail_adding_tools_logs_mask(self): + class ToolInjectingGuardrail(CustomGuardrail): + async def apply_guardrail( + self, + inputs: GenericGuardrailAPIInputs, + request_data: dict[str, object], + input_type: Literal["request", "response"], + logging_obj: Optional["LiteLLMLoggingObj"] = None, + ) -> GenericGuardrailAPIInputs: + return {**inputs, "tools": [{"type": "function", "function": {"name": "guardrail_injected_tool"}}]} + + data = self._request() + await ToolInjectingGuardrail(guardrail_name="g").apply_guardrail( + inputs={"texts": ["SECRET_PROMPT"]}, request_data=data, input_type="request" + ) + + assert self._logged_response(data) == "mask" + + @pytest.mark.asyncio + async def test_apply_guardrail_masking_inputs_in_place_logs_mask(self): + class InPlaceMaskingGuardrail(CustomGuardrail): + async def apply_guardrail( + self, + inputs: GenericGuardrailAPIInputs, + request_data: dict[str, object], + input_type: Literal["request", "response"], + logging_obj: Optional["LiteLLMLoggingObj"] = None, + ) -> GenericGuardrailAPIInputs: + inputs["texts"] = [""] + return inputs + + data = self._request() + await InPlaceMaskingGuardrail(guardrail_name="g").apply_guardrail( + inputs={"texts": ["SECRET_PROMPT"]}, request_data=data, input_type="request" + ) + + assert self._logged_response(data) == "mask" diff --git a/tests/test_litellm/integrations/test_langsmith_init.py b/tests/test_litellm/integrations/test_langsmith_init.py index 025aa86466c..0bc9e279fbf 100644 --- a/tests/test_litellm/integrations/test_langsmith_init.py +++ b/tests/test_litellm/integrations/test_langsmith_init.py @@ -1,5 +1,6 @@ +import asyncio import os -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -154,24 +155,22 @@ class TestLangsmithLoggerInit: assert logger._start_periodic_flush_task() is None mock_get_running_loop.assert_called_once() - @patch("asyncio.get_running_loop") - def test_langsmith_init_starts_periodic_flush_with_running_loop( - self, mock_get_running_loop - ): + @pytest.mark.asyncio + async def test_langsmith_init_starts_periodic_flush_with_running_loop(self): """Test that init schedules periodic flush when a running loop exists.""" - mock_loop = MagicMock() - mock_task = MagicMock() - mock_loop.create_task.return_value = mock_task - mock_get_running_loop.return_value = mock_loop - logger = LangsmithLogger( - langsmith_api_key="test-key", langsmith_project="test-project" + langsmith_api_key="test-key", langsmith_project="test-project", flush_interval=0.01 ) + batch_sent = asyncio.Event() + logger.async_send_batch = AsyncMock(side_effect=batch_sent.set) + logger.log_queue.append({"id": "run-id"}) - assert logger._flush_task == mock_task - mock_loop.create_task.assert_called_once() - scheduled_coro = mock_loop.create_task.call_args.args[0] - scheduled_coro.close() + flush_task = logger._flush_task + assert isinstance(flush_task, asyncio.Task) + await asyncio.wait_for(batch_sent.wait(), timeout=5) + flush_task.cancel() + with pytest.raises(asyncio.CancelledError): + await flush_task @pytest.mark.asyncio async def test_async_log_success_event_lazily_starts_periodic_flush(self): diff --git a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_openai_cache_write_cost.py b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_openai_cache_write_cost.py index 92cb417f96c..18acfeda07d 100644 --- a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_openai_cache_write_cost.py +++ b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_openai_cache_write_cost.py @@ -39,7 +39,7 @@ def test_openai_cache_write_tokens_billed_at_the_cache_creation_rate(local_model input_rate = rates["input_cost_per_token"] cache_write_rate = rates["cache_creation_input_token_cost"] output_rate = rates["output_cost_per_token"] - assert cache_write_rate == pytest.approx(input_rate * 1.25) + assert cache_write_rate > input_rate prompt_tokens = 12317 cache_write_tokens = 12314 diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py index 01f7a2fb7ab..997a97c6fd3 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py @@ -1246,7 +1246,7 @@ async def _flush_logging_worker(capture: "_SuccessPayloadCapture") -> None: await asyncio.sleep(0) try: await asyncio.wait_for(GLOBAL_LOGGING_WORKER.flush(), timeout=10.0) - except (asyncio.TimeoutError, RuntimeError): + except asyncio.TimeoutError: pass deadline = asyncio.get_running_loop().time() + 10.0 while not capture.payloads and asyncio.get_running_loop().time() < deadline: diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_streaming_iterator.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_streaming_iterator.py index be33b2ee3b1..8043496f299 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_streaming_iterator.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_streaming_iterator.py @@ -1,6 +1,7 @@ import asyncio import json from datetime import datetime +from unittest.mock import patch import pytest @@ -824,6 +825,174 @@ async def test_async_sse_wrapper_bills_partial_when_detached_drains_disabled(mon assert len(streaming_iterator_module._DETACHED_STREAM_DRAINS) == 0 +class _SuccessRecorder(CustomLogger): + def __init__(self): + super().__init__() + self.success_kwargs: list = [] + + async def async_log_success_event(self, kwargs, response_obj, start_time, end_time): + self.success_kwargs.append(kwargs) + + +def _make_priced_logging_obj(call_id: str, recorder: _SuccessRecorder, model: str) -> LiteLLMLoggingObj: + logging_obj = LiteLLMLoggingObj( + model=model, + messages=[{"role": "user", "content": "hi"}], + stream=True, + call_type="anthropic_messages", + start_time=datetime.now(), + litellm_call_id=call_id, + function_id=call_id, + dynamic_async_success_callbacks=[recorder], + ) + logging_obj.update_environment_variables( + model=model, + user="", + optional_params={}, + litellm_params={"custom_llm_provider": "anthropic"}, + custom_llm_provider="anthropic", + ) + return logging_obj + + +class _UpstreamClosedOnDetach: + """Upstream that yields its events and then, like a socket read, waits until it is closed.""" + + def __init__(self, events: tuple[dict, ...]): + self._events = iter(events) + self._closed = asyncio.Event() + + def __aiter__(self): + return self + + async def __anext__(self) -> dict: + if self._closed.is_set(): + raise StopAsyncIteration + try: + return next(self._events) + except StopIteration: + await self._closed.wait() + raise StopAsyncIteration + + async def aclose(self) -> None: + self._closed.set() + + +@pytest.mark.asyncio +async def test_client_disconnect_partial_billing_prices_recovered_tokens(monkeypatch): + """ + Regression (LIT-6872): a client disconnect that lands on partial billing + re-tokenizes the buffered text into completion_tokens, but the logged cost + stayed priced at the message_start placeholder (1 output token). The success + row's response_cost must match its recovered completion_tokens. + """ + import litellm + from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER + + monkeypatch.setattr(streaming_iterator_module, "ANTHROPIC_MESSAGES_MAX_DETACHED_STREAM_DRAINS", 0) + monkeypatch.setattr(streaming_iterator_module, "ANTHROPIC_MESSAGES_STREAM_RELAY_QUEUE_MAXSIZE", 4) + model = "claude-sonnet-5" + recorder = _SuccessRecorder() + iterator = BaseAnthropicMessagesStreamingIterator( + litellm_logging_obj=_make_priced_logging_obj("disconnect_partial_cost", recorder, model), + request_body={"model": model, "stream": True}, + ) + sentence = "The history of computing spans centuries of mechanical and electronic invention. " + + async def _stream(): + yield {"type": "message_start", "message": {"id": "msg_1", "usage": {"input_tokens": 29, "output_tokens": 1}}} + yield {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}} + for _ in range(100): + yield {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": sentence}} + yield {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"output_tokens": 1500}} + yield {"type": "message_stop"} + + enqueued: list = [] + + def _capture(async_coroutine): + enqueued.append(async_coroutine) + + with patch.object( # test-quality-ok: GLOBAL_LOGGING_WORKER is a process-global singleton with no injection seam + GLOBAL_LOGGING_WORKER, "ensure_initialized_and_enqueue", side_effect=_capture + ): + gen = iterator.async_sse_wrapper(_stream()) + for _ in range(4): + await gen.__anext__() + await gen.aclose() + for _ in range(500): + if enqueued: + break + await asyncio.sleep(0.01) + + assert len(enqueued) == 1, "client disconnect never reached partial billing" + await enqueued[0] + + assert len(recorder.success_kwargs) == 1 + logged = recorder.success_kwargs[0]["standard_logging_object"] + assert 1 < logged["completion_tokens"] < 1500 + prompt_cost, completion_cost = litellm.cost_per_token( + model=model, prompt_tokens=29, completion_tokens=logged["completion_tokens"] + ) + assert logged["response_cost"] == pytest.approx(prompt_cost + completion_cost) + + +@pytest.mark.asyncio +async def test_proxy_disconnect_closing_upstream_prices_recovered_tokens(): + """ + Regression (LIT-6872), proxy path: after a client disconnect the proxy's + shielded cleanup closes the upstream stream while the pump is still reading + it, so the pump bills the chunks collected so far without ever seeing + message_delta. That row's response_cost must be priced from its recovered + completion_tokens, not from the message_start placeholder. + """ + import litellm + from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER + + model = "claude-sonnet-5" + recorder = _SuccessRecorder() + iterator = BaseAnthropicMessagesStreamingIterator( + litellm_logging_obj=_make_priced_logging_obj("disconnect_upstream_closed", recorder, model), + request_body={"model": model, "stream": True}, + ) + sentence = "The history of computing spans centuries of mechanical and electronic invention. " + upstream = _UpstreamClosedOnDetach( + ( + {"type": "message_start", "message": {"id": "msg_1", "usage": {"input_tokens": 29, "output_tokens": 1}}}, + {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}, + *({"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": sentence}} for _ in range(6)), + ) + ) + enqueued: list = [] + + def _capture(async_coroutine): + enqueued.append(async_coroutine) + + with patch.object( # test-quality-ok: GLOBAL_LOGGING_WORKER is a process-global singleton with no injection seam + GLOBAL_LOGGING_WORKER, "ensure_initialized_and_enqueue", side_effect=_capture + ): + gen = iterator.async_sse_wrapper(upstream) + for _ in range(4): + await gen.__anext__() + await gen.aclose() + assert not enqueued, "billing must wait for the upstream read to end, not the client detach" + await upstream.aclose() + for _ in range(500): + if enqueued: + break + await asyncio.sleep(0.01) + + assert len(enqueued) == 1, "closing the upstream never reached partial billing" + await enqueued[0] + + assert len(recorder.success_kwargs) == 1 + logged = recorder.success_kwargs[0]["standard_logging_object"] + assert logged["completion_tokens"] > 1 + prompt_cost, completion_cost = litellm.cost_per_token( + model=model, prompt_tokens=29, completion_tokens=logged["completion_tokens"] + ) + assert logged["response_cost"] == pytest.approx(prompt_cost + completion_cost) + + @pytest.mark.asyncio async def test_async_sse_wrapper_aborts_upstream_when_detached_drain_cap_reached(monkeypatch): """ diff --git a/tests/test_litellm/llms/azure_ai/ocr/test_azure_ai_cohere_parse_transformation.py b/tests/test_litellm/llms/azure_ai/ocr/test_azure_ai_cohere_parse_transformation.py index 3f98e9b6a2d..2f457fcb25b 100644 --- a/tests/test_litellm/llms/azure_ai/ocr/test_azure_ai_cohere_parse_transformation.py +++ b/tests/test_litellm/llms/azure_ai/ocr/test_azure_ai_cohere_parse_transformation.py @@ -1,9 +1,5 @@ -import base64 -import json - import pytest -import litellm from litellm.llms.azure_ai.ocr.cohere_parse_transformation import AzureAICohereParseConfig from litellm.llms.azure_ai.ocr.common_utils import get_azure_ai_ocr_config from litellm.llms.azure_ai.ocr.document_intelligence.transformation import AzureDocumentIntelligenceOCRConfig @@ -12,27 +8,6 @@ from litellm.llms.azure_ai.ocr.transformation import AzureAIOCRConfig MODEL = "azure_ai/Cohere-parse-v5" API_BASE = "https://resource.services.ai.azure.com" PARSE_URL = f"{API_BASE}/providers/cohere/v2/parse" -IMAGE_URL = "https://example.com/receipt.png" -PNG_BYTES = base64.b64decode( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" -) -PNG_DATA_URI = f"data:image/png;base64,{base64.b64encode(PNG_BYTES).decode()}" - - -def _parse_response() -> dict: - return { - "id": "882bf973-9dfa-4d02-9d30-709247008efd", - "pages": [{"index": 0, "type": "markdown", "markdown": {"content": "# Receipt\n\nTotal Due: $4.00"}}], - "meta": {"api_version": {"version": "2"}, "billed_units": {"pages": 1}}, - } - - -@pytest.fixture() -def disable_aiohttp_transport(monkeypatch): - monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) - litellm.in_memory_llm_clients_cache.flush_cache() - yield - litellm.in_memory_llm_clients_cache.flush_cache() @pytest.mark.parametrize( @@ -95,90 +70,3 @@ def test_validate_environment_requires_api_base(monkeypatch) -> None: with pytest.raises(ValueError, match="AZURE_AI_API_BASE"): AzureAICohereParseConfig().validate_environment(headers={}, model="Cohere-parse-v5", api_key="key") - - -@pytest.mark.asyncio -async def test_aocr_inlines_remote_image_and_posts_to_foundry(disable_aiohttp_transport, respx_mock): - respx_mock.get(IMAGE_URL).respond(content=PNG_BYTES, headers={"Content-Type": "image/png"}) - route = respx_mock.post(PARSE_URL).respond(json=_parse_response()) - - response = await litellm.aocr( - model=MODEL, - document={"type": "image_url", "image_url": IMAGE_URL}, - api_base=API_BASE, - api_key="azure-key", - ) - - request = route.calls.last.request - assert request.headers["Authorization"] == "Bearer azure-key" - assert json.loads(request.content) == { - "model": "Cohere-parse-v5", - "document": {"type": "image_url", "image_url": PNG_DATA_URI}, - "output_format": "markdown", - } - assert response.pages[0].markdown == "# Receipt\n\nTotal Due: $4.00" - assert response.usage_info.pages_processed == 1 - - -@pytest.mark.asyncio -async def test_aocr_passes_data_uri_through_without_fetching(disable_aiohttp_transport, respx_mock): - route = respx_mock.post(PARSE_URL).respond(json=_parse_response()) - - await litellm.aocr( - model=MODEL, - document={"type": "image_url", "image_url": PNG_DATA_URI}, - api_base=API_BASE, - api_key="azure-key", - output_format="blocks", - ) - - body = json.loads(route.calls.last.request.content) - assert body["document"]["image_url"] == PNG_DATA_URI - assert body["output_format"] == "blocks" - - -def test_ocr_sync_inlines_remote_image(respx_mock): - respx_mock.get(IMAGE_URL).respond(content=PNG_BYTES, headers={"Content-Type": "image/png"}) - route = respx_mock.post(PARSE_URL).respond(json=_parse_response()) - - response = litellm.ocr( - model=MODEL, - document={"type": "image_url", "image_url": IMAGE_URL}, - api_base=API_BASE, - api_key="azure-key", - ) - - assert json.loads(route.calls.last.request.content)["document"]["image_url"] == PNG_DATA_URI - assert response.pages[0].markdown == "# Receipt\n\nTotal Due: $4.00" - - -@pytest.mark.asyncio -async def test_aocr_rejects_pdf_before_calling_foundry(disable_aiohttp_transport, respx_mock): - route = respx_mock.post(PARSE_URL).respond(json=_parse_response()) - - with pytest.raises(litellm.BadRequestError, match="only accepts `image_url` documents") as exc_info: - await litellm.aocr( - model=MODEL, - document={"type": "document_url", "document_url": "https://example.com/doc.pdf"}, - api_base=API_BASE, - api_key="azure-key", - ) - - assert exc_info.value.llm_provider == "azure_ai" - assert not route.called - - -@pytest.mark.asyncio -async def test_ahealth_check_ocr_sends_an_image_to_the_foundry_cohere_parse_deployment( - disable_aiohttp_transport, respx_mock -): - route = respx_mock.post(PARSE_URL).respond(json=_parse_response()) - - result = await litellm.ahealth_check( - model_params={"model": MODEL, "api_base": API_BASE, "api_key": "test-key"}, mode="ocr" - ) - - document = json.loads(route.calls.last.request.content)["document"] - assert document["type"] == "image_url" - assert document["image_url"].startswith("data:image/png;base64,") - assert "error" not in result diff --git a/tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py b/tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py index ef4c78553f1..be0dfb5724e 100644 --- a/tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py +++ b/tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py @@ -1,4 +1,5 @@ from unittest.mock import MagicMock +from typing import Final import httpx import pytest @@ -371,3 +372,35 @@ def test_validate_environment_falls_back_to_entra_token(monkeypatch): assert headers["Authorization"] == "Bearer entra-token" assert "Ocp-Apim-Subscription-Key" not in headers + + +@pytest.mark.parametrize( + ("request_headers", "expected_poll_headers"), + ( + ( + {"Ocp-Apim-Subscription-Key": "subscription-key"}, + {"Ocp-Apim-Subscription-Key": "subscription-key"}, + ), + ( + {"Authorization": "Bearer entra-token"}, + {"Authorization": "Bearer entra-token"}, + ), + ), +) +def test_get_polling_target_preserves_request_authentication( + request_headers: dict[str, str], expected_poll_headers: dict[str, str] +) -> None: + response: Final = httpx.Response( + status_code=202, + headers={"Operation-Location": "https://example.cognitiveservices.azure.com/operations/123"}, + request=httpx.Request( + "POST", + "https://example.cognitiveservices.azure.com/documentintelligence/documentModels/prebuilt-layout:analyze", + headers=request_headers, + ), + ) + + operation_url, poll_headers = AzureDocumentIntelligenceOCRConfig()._get_polling_target(response) + + assert operation_url == "https://example.cognitiveservices.azure.com/operations/123" + assert poll_headers == expected_poll_headers diff --git a/tests/test_litellm/llms/cohere/ocr/test_cohere_parse_transformation.py b/tests/test_litellm/llms/cohere/ocr/test_cohere_parse_transformation.py index cb9af56f5e0..1f120be6ffa 100644 --- a/tests/test_litellm/llms/cohere/ocr/test_cohere_parse_transformation.py +++ b/tests/test_litellm/llms/cohere/ocr/test_cohere_parse_transformation.py @@ -1,8 +1,11 @@ -import json +from typing import Final +from unittest.mock import Mock +import httpx import pytest import litellm +from litellm.llms.cohere.ocr.transformation import CohereParseConfig PARSE_URL = "https://api.cohere.com/v2/parse" MODEL = "cohere/parse-v5.0" @@ -57,173 +60,38 @@ def _blocks_response() -> dict: } -@pytest.fixture() -def disable_aiohttp_transport(monkeypatch): - monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) - litellm.in_memory_llm_clients_cache.flush_cache() - yield - litellm.in_memory_llm_clients_cache.flush_cache() - - -@pytest.mark.asyncio -async def test_aocr_sends_markdown_parse_request_and_normalizes_pages(disable_aiohttp_transport, respx_mock): - route = respx_mock.post(PARSE_URL).respond(json=_markdown_response()) - - response = await litellm.aocr(model=MODEL, document=IMAGE_DOCUMENT, api_key="test-key") - - request = route.calls.last.request - assert request.headers["Authorization"] == "Bearer test-key" - assert json.loads(request.content) == { - "model": "parse-v5.0", - "document": IMAGE_DOCUMENT, - "output_format": "markdown", - } - assert response.object == "ocr" - assert [page.index for page in response.pages] == [0, 1] - assert response.pages[0].markdown == "# Receipt\n\nTotal Due: $4.00" - assert response.pages[1].markdown == "Page two" - assert response.pages[1].images is None - image = response.pages[0].images[0] - assert image.bbox == BOUNDING_BOX - assert image.model_extra["description"] == "A parking receipt" - assert image.model_extra["bounding_box_normalized"]["bottom_right_x"] == 1 - assert response.usage_info.pages_processed == 2 - assert response.get_provider_native_response() is None - - -@pytest.mark.asyncio -async def test_aocr_usage_prefers_billed_units_over_page_count(disable_aiohttp_transport, respx_mock): - respx_mock.post(PARSE_URL).respond(json=_markdown_response(billed_pages=3)) - - response = await litellm.aocr(model=MODEL, document=IMAGE_DOCUMENT, api_key="test-key") - - assert response.usage_info.pages_processed == 3 - - -@pytest.mark.asyncio -async def test_aocr_usage_falls_back_to_page_count_without_meta(disable_aiohttp_transport, respx_mock): - respx_mock.post(PARSE_URL).respond(json=_markdown_response(billed_pages=None)) - - response = await litellm.aocr(model=MODEL, document=IMAGE_DOCUMENT, api_key="test-key") - - assert response.usage_info.pages_processed == 2 - - -@pytest.mark.asyncio -async def test_aocr_blocks_output_format_forwards_param_and_keeps_blocks(disable_aiohttp_transport, respx_mock): - route = respx_mock.post(PARSE_URL).respond(json=_blocks_response()) - - response = await litellm.aocr(model=MODEL, document=IMAGE_DOCUMENT, api_key="test-key", output_format="blocks") - - assert json.loads(route.calls.last.request.content)["output_format"] == "blocks" - assert response.pages[0].markdown == "" - assert response.pages[0].model_extra["blocks"] == [{"type": "text", "text": "Total Due: $4.00"}] - assert response.usage_info.pages_processed == 1 - - -@pytest.mark.asyncio -async def test_aocr_native_format_carries_provider_payload(disable_aiohttp_transport, respx_mock): - payload = _markdown_response() - route = respx_mock.post(PARSE_URL).respond(json=payload) - - response = await litellm.aocr(model=MODEL, document=IMAGE_DOCUMENT, api_key="test-key", req_format="native") - - assert "req_format" not in json.loads(route.calls.last.request.content) - assert response.get_provider_native_response() == payload - assert response.pages[0].markdown == "# Receipt\n\nTotal Due: $4.00" - - -@pytest.mark.asyncio -async def test_aocr_rejects_unknown_output_format_before_calling_provider(disable_aiohttp_transport, respx_mock): - route = respx_mock.post(PARSE_URL).respond(json=_markdown_response()) - - with pytest.raises(litellm.BadRequestError, match="Invalid `output_format`: 'html'") as exc_info: - await litellm.aocr(model=MODEL, document=IMAGE_DOCUMENT, api_key="test-key", output_format="html") - - assert exc_info.value.status_code == 400 - assert not route.called - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "document", - [ - {"type": "document_url", "document_url": "https://example.com/doc.pdf"}, - {"type": "image_url", "image_url": "data:application/pdf;base64,JVBERi0="}, - {"type": "image_url", "image_url": ""}, - ], -) -async def test_aocr_rejects_non_image_documents_before_calling_provider( - disable_aiohttp_transport, respx_mock, document -): - route = respx_mock.post(PARSE_URL).respond(json=_markdown_response()) - - with pytest.raises(litellm.BadRequestError, match="only accepts `image_url` documents") as exc_info: - await litellm.aocr(model=MODEL, document=document, api_key="test-key") - - assert exc_info.value.status_code == 400 - assert not route.called - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "api_base, expected_url", - [ - ("https://gateway.example.com", "https://gateway.example.com/v2/parse"), - ("https://gateway.example.com/cohere/", "https://gateway.example.com/cohere/v2/parse"), - ("https://gateway.example.com/v2", "https://gateway.example.com/v2/parse"), - ("https://gateway.example.com/v2/parse", "https://gateway.example.com/v2/parse"), - ], -) -async def test_aocr_posts_to_api_base_variants(disable_aiohttp_transport, respx_mock, api_base, expected_url): - route = respx_mock.post(expected_url).respond(json=_markdown_response()) - - await litellm.aocr(model=MODEL, document=IMAGE_DOCUMENT, api_key="test-key", api_base=api_base) - - assert route.called - - -@pytest.mark.asyncio -async def test_aocr_surfaces_provider_error_with_its_status_and_message(disable_aiohttp_transport, respx_mock): - respx_mock.post(PARSE_URL).respond( - status_code=400, json={"id": "83b0d95e", "message": "output_format must be `blocks` or `markdown`"} +@pytest.mark.parametrize("output_format", ["markdown", "blocks"]) +def test_transform_cohere_request_filters_options(output_format: str) -> None: + config: Final = CohereParseConfig() + params: Final = config.map_ocr_params( + {"output_format": output_format, "req_format": "native", "unknown": True}, {}, "parse-v5.0" ) - - with pytest.raises(litellm.BadRequestError, match="output_format must be") as exc_info: - await litellm.aocr(model=MODEL, document=IMAGE_DOCUMENT, api_key="test-key") - - assert exc_info.value.status_code == 400 + request: Final = config.transform_ocr_request("parse-v5.0", IMAGE_DOCUMENT, params, {}) + assert request.data == {"model": "parse-v5.0", "document": IMAGE_DOCUMENT, "output_format": output_format} -@pytest.mark.asyncio -async def test_aocr_reads_api_key_from_environment(disable_aiohttp_transport, respx_mock, monkeypatch): - monkeypatch.setenv("COHERE_API_KEY", "env-key") - route = respx_mock.post(PARSE_URL).respond(json=_markdown_response()) - - await litellm.aocr(model=MODEL, document=IMAGE_DOCUMENT) - - assert route.calls.last.request.headers["Authorization"] == "Bearer env-key" +@pytest.mark.parametrize("native", [False, True]) +def test_transform_cohere_response_keeps_images_and_native_payload(native: bool) -> None: + payload: Final = _markdown_response(3) + response: Final = CohereParseConfig().transform_ocr_response( + "parse-v5.0", httpx.Response(200, json=payload), Mock(), {"req_format": "native" if native else "litellm"} + ) + assert response.pages[0].markdown == "# Receipt\n\nTotal Due: $4.00" + assert response.pages[0].images[0].bbox == BOUNDING_BOX + assert response.pages[0].images[0].model_extra["description"] == "A parking receipt" + assert response.pages[1].images is None + assert response.usage_info.pages_processed == 3 + assert response.get_provider_native_response() == (payload if native else None) -@pytest.mark.asyncio -async def test_aocr_without_api_key_names_the_env_var(disable_aiohttp_transport, respx_mock, monkeypatch): - monkeypatch.delenv("COHERE_API_KEY", raising=False) - monkeypatch.setattr(litellm, "cohere_key", None) - route = respx_mock.post(PARSE_URL).respond(json=_markdown_response()) - - with pytest.raises(Exception, match="Missing COHERE_API_KEY"): - await litellm.aocr(model=MODEL, document=IMAGE_DOCUMENT) - - assert not route.called +def test_transform_cohere_blocks() -> None: + response: Final = CohereParseConfig().transform_ocr_response( + "parse-v5.0", httpx.Response(200, json=_blocks_response()), Mock() + ) + assert response.pages[0].model_extra["blocks"] == [{"type": "text", "text": "Total Due: $4.00"}] + assert response.pages[0].markdown == "" -@pytest.mark.asyncio -async def test_ahealth_check_ocr_sends_an_image_cohere_parse_accepts(disable_aiohttp_transport, respx_mock): - route = respx_mock.post(PARSE_URL).respond(json=_markdown_response()) - - result = await litellm.ahealth_check(model_params={"model": MODEL, "api_key": "test-key"}, mode="ocr") - - document = json.loads(route.calls.last.request.content)["document"] - assert document["type"] == "image_url" - assert document["image_url"].startswith("data:image/png;base64,") - assert "error" not in result +def test_transform_cohere_rejects_unsupported_output_format() -> None: + with pytest.raises(litellm.UnsupportedParamsError, match="output_format"): + CohereParseConfig().map_ocr_params({"output_format": "html"}, {}, "parse-v5.0") diff --git a/tests/test_litellm/llms/reducto/conftest.py b/tests/test_litellm/llms/reducto/conftest.py new file mode 100644 index 00000000000..4ff3ab43006 --- /dev/null +++ b/tests/test_litellm/llms/reducto/conftest.py @@ -0,0 +1,11 @@ +from collections.abc import Generator + +import pytest + +from tests.test_litellm_rust.support.recording_server import RecordingServer, recording_service + + +@pytest.fixture +def reducto_server() -> Generator[RecordingServer]: + with recording_service() as server: + yield server diff --git a/tests/test_litellm/llms/reducto/test_parse_legacy.py b/tests/test_litellm/llms/reducto/test_parse_legacy.py index db19460baa3..252369cbd3d 100644 --- a/tests/test_litellm/llms/reducto/test_parse_legacy.py +++ b/tests/test_litellm/llms/reducto/test_parse_legacy.py @@ -1,7 +1,7 @@ -import json +import pytest import litellm -import pytest +from tests.test_litellm_rust.support.recording_server import RecordingServer, ResponseSpec @pytest.fixture() @@ -17,24 +17,28 @@ def disable_aiohttp_transport(): @pytest.mark.asyncio -async def test_parse_legacy_wraps_enhance_under_options( - disable_aiohttp_transport, respx_mock -): - upload_route = respx_mock.post("https://platform.reducto.ai/upload").respond( - json={"file_id": "reducto://legacy.pdf"} - ) - parse_route = respx_mock.post("https://platform.reducto.ai/parse").respond( - json={ - "usage": {"num_pages": 1, "credits": 1}, - "result": { - "chunks": [ - { - "content": "Legacy parse", - "blocks": [{"content": "Legacy parse", "bbox": {"page": 1}}], - } - ] - }, - } +async def test_parse_legacy_wraps_enhance_under_options(disable_aiohttp_transport, reducto_server: RecordingServer): + reducto_server.expected_requests = 2 + reducto_server.enqueue(ResponseSpec(body={"file_id": "reducto://legacy.pdf"})) + reducto_server.enqueue( + ResponseSpec( + body={ + "usage": {"num_pages": 1, "credits": 1}, + "result": { + "chunks": [ + { + "content": "Legacy parse", + "blocks": [ + { + "content": "Legacy parse", + "bbox": {"page": 1}, + } + ], + } + ] + }, + } + ) ) response = await litellm.aocr( @@ -45,13 +49,15 @@ async def test_parse_legacy_wraps_enhance_under_options( "mime_type": "application/pdf", }, api_key="legacy-key", - api_base="https://platform.reducto.ai", + api_base=reducto_server.base_url, enhance={"agentic": [{"type": "table"}]}, ) - assert upload_route.called - assert parse_route.called - request_body = json.loads(parse_route.calls[0].request.read()) + upload_request, parse_request = reducto_server.requests + assert upload_request.path == "/upload" + assert parse_request.path == "/parse" + assert isinstance(parse_request.body, dict) + request_body = parse_request.body assert request_body == { "document_url": "reducto://legacy.pdf", "options": {"enhance": {"agentic": [{"type": "table"}]}}, diff --git a/tests/test_litellm/llms/reducto/test_parse_v3.py b/tests/test_litellm/llms/reducto/test_parse_v3.py index 1d0c826ef8b..0ebc0d926c4 100644 --- a/tests/test_litellm/llms/reducto/test_parse_v3.py +++ b/tests/test_litellm/llms/reducto/test_parse_v3.py @@ -1,8 +1,7 @@ -import json - import pytest import litellm +from tests.test_litellm_rust.support.recording_server import RecordingServer, ResponseSpec def _reducto_parse_response() -> dict: @@ -69,11 +68,11 @@ def disable_aiohttp_transport(): @pytest.mark.asyncio -async def test_parse_v3_file_upload_and_response_mapping(disable_aiohttp_transport, respx_mock): - upload_route = respx_mock.post("https://platform.reducto.ai/upload").respond( - json={"file_id": "reducto://uploaded.pdf"} - ) - parse_route = respx_mock.post("https://platform.reducto.ai/parse").respond(json=_reducto_parse_response()) +async def test_parse_v3_file_upload_and_response_mapping(disable_aiohttp_transport, reducto_server: RecordingServer): + reducto_server.expected_requests = 2 + provider_response = _reducto_parse_response() + reducto_server.enqueue(ResponseSpec(body={"file_id": "reducto://uploaded.pdf"})) + reducto_server.enqueue(ResponseSpec(body=provider_response)) response = await litellm.aocr( model="reducto/parse-v3", @@ -83,25 +82,24 @@ async def test_parse_v3_file_upload_and_response_mapping(disable_aiohttp_transpo "mime_type": "application/pdf", }, api_key="test-key", - api_base="https://platform.reducto.ai", + api_base=reducto_server.base_url, formatting={"table_output_format": "html"}, retrieval={"chunk_mode": "section"}, settings={"ocr_system": "standard"}, + req_format="native", ) - assert upload_route.called - assert parse_route.called - assert len(upload_route.calls) == 1 - assert len(parse_route.calls) == 1 - - upload_request = upload_route.calls[0].request + upload_request, parse_request = reducto_server.requests + assert upload_request.path == "/upload" + assert parse_request.path == "/parse" assert upload_request.headers["authorization"] == "Bearer test-key" assert "application/json" not in upload_request.headers["content-type"] - upload_body = upload_request.read() + upload_body = upload_request.raw_body assert b'filename="document"' in upload_body assert b"application/pdf" in upload_body - parse_request_body = json.loads(parse_route.calls[0].request.read()) + assert isinstance(parse_request.body, dict) + parse_request_body = parse_request.body assert parse_request_body["input"] == "reducto://uploaded.pdf" assert parse_request_body["formatting"] == {"table_output_format": "html"} assert parse_request_body["retrieval"] == {"chunk_mode": "section"} @@ -116,15 +114,12 @@ async def test_parse_v3_file_upload_and_response_mapping(disable_aiohttp_transpo assert getattr(response.pages[0], "blocks")[0]["bbox"]["page"] == 1 assert response.pages[1].markdown == "Page 2 block A" assert response.pages[2].markdown == "Page 3 block A" - assert response._hidden_params["reducto_raw"]["usage"]["credits"] == 3 + assert response.get_provider_native_response() == provider_response @pytest.mark.asyncio -async def test_parse_v3_reducto_id_passthrough_skips_upload(disable_aiohttp_transport, respx_mock): - upload_route = respx_mock.post("https://platform.reducto.ai/upload").respond( - json={"file_id": "reducto://should-not-upload.pdf"} - ) - parse_route = respx_mock.post("https://platform.reducto.ai/parse").respond(json=_reducto_parse_response()) +async def test_parse_v3_reducto_id_passthrough_skips_upload(disable_aiohttp_transport, reducto_server: RecordingServer): + reducto_server.enqueue(ResponseSpec(body=_reducto_parse_response())) response = await litellm.aocr( model="reducto/parse-v3", @@ -133,13 +128,15 @@ async def test_parse_v3_reducto_id_passthrough_skips_upload(disable_aiohttp_tran "document_url": "reducto://already-uploaded.pdf", }, api_key="test-key", - api_base="https://platform.reducto.ai", + api_base=reducto_server.base_url, retrieval={"chunk_mode": "section"}, ) - assert not upload_route.called - assert parse_route.called - parse_request_body = json.loads(parse_route.calls[0].request.read()) + assert len(reducto_server.requests) == 1 + parse_request = reducto_server.requests[0] + assert parse_request.path == "/parse" + assert isinstance(parse_request.body, dict) + parse_request_body = parse_request.body assert parse_request_body["input"] == "reducto://already-uploaded.pdf" assert parse_request_body["retrieval"]["chunk_mode"] == "section" assert response.pages[0].markdown.startswith("Page 1 block A") @@ -147,11 +144,9 @@ async def test_parse_v3_reducto_id_passthrough_skips_upload(disable_aiohttp_tran @pytest.mark.asyncio async def test_unknown_model_uses_current_protocol_without_local_rejection( - disable_aiohttp_transport, respx_mock + disable_aiohttp_transport, reducto_server: RecordingServer ): - parse_route = respx_mock.post("https://platform.reducto.ai/parse").respond( - json=_reducto_parse_response() - ) + reducto_server.enqueue(ResponseSpec(body=_reducto_parse_response())) response = await litellm.aocr( model="reducto/future-parse-model", @@ -160,11 +155,9 @@ async def test_unknown_model_uses_current_protocol_without_local_rejection( "document_url": "reducto://already-uploaded.pdf", }, api_key="test-key", - api_base="https://platform.reducto.ai", + api_base=reducto_server.base_url, ) - assert parse_route.called - assert json.loads(parse_route.calls[0].request.read()) == { - "input": "reducto://already-uploaded.pdf" - } + assert reducto_server.requests[0].path == "/parse" + assert reducto_server.requests[0].body == {"input": "reducto://already-uploaded.pdf"} assert response.model == "future-parse-model" diff --git a/tests/test_litellm/llms/reducto/test_upload.py b/tests/test_litellm/llms/reducto/test_upload.py index 4fae90436bb..adfc2663fb0 100644 --- a/tests/test_litellm/llms/reducto/test_upload.py +++ b/tests/test_litellm/llms/reducto/test_upload.py @@ -1,16 +1,16 @@ -import json import os from unittest.mock import AsyncMock, Mock import httpx -import litellm import pytest +import litellm from litellm.llms.reducto.common import ( extract_file_id_or_bytes, upload_bytes_async, upload_bytes_sync, ) +from tests.test_litellm_rust.support.recording_server import RecordingServer, ResponseSpec @pytest.fixture() @@ -28,7 +28,8 @@ def disable_aiohttp_transport(monkeypatch): @pytest.mark.asyncio -async def test_parse_v3_rejects_plain_http_urls(disable_aiohttp_transport): +async def test_parse_v3_rejects_plain_http_urls(disable_aiohttp_transport, reducto_server: RecordingServer): + reducto_server.expected_requests = 0 with pytest.raises(litellm.BadRequestError, match="upload the file first"): await litellm.aocr( model="reducto/parse-v3", @@ -37,29 +38,30 @@ async def test_parse_v3_rejects_plain_http_urls(disable_aiohttp_transport): "document_url": "https://example.com/document.pdf", }, api_key="test-key", - api_base="https://platform.reducto.ai", + api_base=reducto_server.base_url, ) @pytest.mark.asyncio async def test_parse_v3_image_data_uri_upload_uses_image_mime( - disable_aiohttp_transport, respx_mock + disable_aiohttp_transport, reducto_server: RecordingServer ): - upload_route = respx_mock.post("https://custom.reducto.test/upload").respond( - json={"file_id": "reducto://uploaded-image.png"} - ) - parse_route = respx_mock.post("https://custom.reducto.test/parse").respond( - json={ - "usage": {"num_pages": 1, "credits": 1}, - "result": { - "chunks": [ - { - "content": "Image OCR", - "blocks": [{"content": "Image OCR", "bbox": {"page": 1}}], - } - ] - }, - } + reducto_server.expected_requests = 2 + reducto_server.enqueue(ResponseSpec(body={"file_id": "reducto://uploaded-image.png"})) + reducto_server.enqueue( + ResponseSpec( + body={ + "usage": {"num_pages": 1, "credits": 1}, + "result": { + "chunks": [ + { + "content": "Image OCR", + "blocks": [{"content": "Image OCR", "bbox": {"page": 1}}], + } + ] + }, + } + ) ) response = await litellm.aocr( @@ -70,41 +72,43 @@ async def test_parse_v3_image_data_uri_upload_uses_image_mime( "mime_type": "image/png", }, api_key="programmatic-key", - api_base="https://custom.reducto.test/", + api_base=f"{reducto_server.base_url}/", ) - assert upload_route.called - assert parse_route.called - upload_request = upload_route.calls[0].request + upload_request, parse_request = reducto_server.requests + assert upload_request.path == "/upload" + assert parse_request.path == "/parse" assert upload_request.headers["authorization"] == "Bearer programmatic-key" - assert b"image/png" in upload_request.read() + assert b"image/png" in upload_request.raw_body - parse_request_body = json.loads(parse_route.calls[0].request.read()) - assert parse_request_body["input"] == "reducto://uploaded-image.png" + assert isinstance(parse_request.body, dict) + assert parse_request.body["input"] == "reducto://uploaded-image.png" assert response.pages[0].markdown == "Image OCR" @pytest.mark.asyncio -async def test_parse_v3_uses_programmatic_api_key_over_env( - disable_aiohttp_transport, respx_mock -): - upload_route = respx_mock.post("https://platform.reducto.ai/upload").respond( - json={"file_id": "reducto://uploaded.pdf"} - ) - parse_route = respx_mock.post("https://platform.reducto.ai/parse").respond( - json={ - "usage": {"num_pages": 1, "credits": 1}, - "result": { - "chunks": [ - { - "content": "Programmatic auth", - "blocks": [ - {"content": "Programmatic auth", "bbox": {"page": 1}} - ], - } - ] - }, - } +async def test_parse_v3_uses_programmatic_api_key_over_env(disable_aiohttp_transport, reducto_server: RecordingServer): + reducto_server.expected_requests = 2 + reducto_server.enqueue(ResponseSpec(body={"file_id": "reducto://uploaded.pdf"})) + reducto_server.enqueue( + ResponseSpec( + body={ + "usage": {"num_pages": 1, "credits": 1}, + "result": { + "chunks": [ + { + "content": "Programmatic auth", + "blocks": [ + { + "content": "Programmatic auth", + "bbox": {"page": 1}, + } + ], + } + ] + }, + } + ) ) await litellm.aocr( @@ -115,11 +119,11 @@ async def test_parse_v3_uses_programmatic_api_key_over_env( "mime_type": "application/pdf", }, api_key="passed-key", - api_base="https://platform.reducto.ai", + api_base=reducto_server.base_url, ) - assert upload_route.calls[0].request.headers["authorization"] == "Bearer passed-key" - assert parse_route.calls[0].request.headers["authorization"] == "Bearer passed-key" + assert reducto_server.requests[0].headers["authorization"] == "Bearer passed-key" + assert reducto_server.requests[1].headers["authorization"] == "Bearer passed-key" def test_upload_bytes_sync_uses_shared_client(monkeypatch): diff --git a/tests/test_litellm/ocr/test_legacy.py b/tests/test_litellm/ocr/test_legacy.py new file mode 100644 index 00000000000..a30976f89af --- /dev/null +++ b/tests/test_litellm/ocr/test_legacy.py @@ -0,0 +1,200 @@ +import importlib +from collections.abc import AsyncGenerator +from datetime import datetime +from io import BytesIO +from typing import Final +from unittest.mock import Mock + +import httpx +import orjson +import pytest + +import litellm +from litellm.integrations.custom_logger import CustomLogger +from litellm.llms.base_llm.ocr.transformation import OCRResponse +from litellm.llms.custom_httpx import llm_http_handler +from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler +from litellm.ocr.legacy import _prepare_ocr_request +from litellm.rust_bridge import bindings, configuration +from litellm.rust_bridge.ocr_lifecycle import NATIVE_OCR_LIFECYCLE + + +@pytest.fixture +async def provider(monkeypatch: pytest.MonkeyPatch) -> AsyncGenerator[Mock]: + configuration.reset_rust_configuration() + monkeypatch.setenv("LITELLM_RUST", "0") + monkeypatch.setattr(bindings, "get_native_bridge", Mock(side_effect=AssertionError("Rust must not load"))) + handler: Final = Mock( + return_value=httpx.Response( + 200, + json={ + "pages": [{"index": 0, "markdown": "parsed document"}], + "model": "mistral-ocr-latest", + "usage_info": {"pages_processed": 1}, + }, + ) + ) + transport: Final = httpx.MockTransport(handler) + with httpx.Client(transport=transport) as sync_client: + async with httpx.AsyncClient(transport=transport) as async_client: + sync_handler: Final = HTTPHandler(client=sync_client) + async_handler: Final = AsyncHTTPHandler() + await async_handler.client.aclose() + async_handler.client = async_client + monkeypatch.setattr(llm_http_handler, "_get_httpx_client", lambda: sync_handler) + monkeypatch.setattr(llm_http_handler, "get_async_httpx_client", lambda llm_provider: async_handler) + yield handler + NATIVE_OCR_LIFECYCLE.reset() + configuration.reset_rust_configuration() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("mode", ["sync", "async", "sync_async"]) +@pytest.mark.parametrize("dispatch", ["disabled", "declined", "unavailable"]) +async def test_python_request_response_and_callbacks( + provider: Mock, monkeypatch: pytest.MonkeyPatch, mode: str, dispatch: str +) -> None: + class Declined(Exception): + pass + + if dispatch != "disabled": + monkeypatch.setenv("LITELLM_RUST", "1") + NATIVE_OCR_LIFECYCLE.override(Mock(side_effect=Declined()) if dispatch == "declined" else None) + main: Final = importlib.import_module("litellm.ocr.main") + monkeypatch.setattr(main, "native_exception_types", lambda: (Declined, RuntimeError)) + logger: Final = Mock(spec=CustomLogger) + monkeypatch.setattr(litellm, "input_callback", [logger]) + arguments: Final = { + "model": "mistral/mistral-ocr-latest", + "document": {"type": "file", "file": BytesIO(b"pdf"), "mime_type": "application/pdf"}, + "api_key": "test-key", + "api_base": "https://ocr.test/v1", + "timeout": 7.0, + "pages": [0, 2], + "include_image_base64": True, + "extra_headers": {"x-test-header": "preserved"}, + } + + async def call() -> OCRResponse: + if mode == "async": + return await litellm.aocr(**arguments) + if mode == "sync_async": + from litellm.litellm_core_utils.litellm_logging import Logging + + logging_obj: Final = Logging( + model=arguments["model"], + messages=[], + stream=False, + call_type="aocr", + start_time=datetime.now(), + litellm_call_id="test-call", + function_id="test-function", + ) + return await litellm.ocr(**arguments, aocr=True, litellm_logging_obj=logging_obj) + return litellm.ocr(**arguments) + + response: Final = await call() + assert response.pages[0].markdown == "parsed document" + assert response.usage_info.pages_processed == 1 + assert provider.call_count == 1 + request: Final = provider.call_args.args[0] + assert str(request.url) == "https://ocr.test/v1/ocr" + assert request.headers["authorization"] == "Bearer test-key" + assert request.headers["x-test-header"] == "preserved" + assert request.extensions["timeout"] == {"connect": 7.0, "read": 7.0, "write": 7.0, "pool": 7.0} + assert orjson.loads(request.content) == { + "model": "mistral-ocr-latest", + "document": {"type": "document_url", "document_url": "data:application/pdf;base64,cGRm"}, + "pages": [0, 2], + "include_image_base64": True, + } + assert logger.log_pre_api_call.call_count == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("asynchronous", [False, True]) +async def test_python_provider_errors_keep_public_exception(provider: Mock, asynchronous: bool) -> None: + provider.return_value = httpx.Response(429, json={"error": "rate limited"}) + arguments: Final = { + "model": "mistral/mistral-ocr-latest", + "document": {"type": "document_url", "document_url": "https://example.com/file.pdf"}, + "api_key": "test-key", + "api_base": "https://ocr.test/v1", + "num_retries": 0, + } + + async def call() -> object: + if asynchronous: + return await litellm.aocr(**arguments) + return litellm.ocr(**arguments) + + with pytest.raises(litellm.RateLimitError) as error: + await call() + assert error.value.status_code == 429 + assert error.value.model == "mistral-ocr-latest" + assert error.value.llm_provider == "mistral" + assert provider.call_count == 1 + + +def test_document_intelligence_environment_key_is_not_replaced_by_generic_azure_key( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("AZURE_AI_API_KEY", "generic-key") + monkeypatch.setenv("AZURE_DOCUMENT_INTELLIGENCE_API_KEY", "document-key") + monkeypatch.setenv("AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT", "https://document.example.com") + prepared: Final = _prepare_ocr_request( + model="azure_ai/doc-intelligence/prebuilt-layout", + document={"type": "document_url", "document_url": "https://example.com/file.pdf"}, + api_key=None, + api_base=None, + timeout=None, + custom_llm_provider=None, + extra_headers=None, + kwargs={"litellm_logging_obj": Mock()}, + ) + + assert prepared.api_key is None + headers: Final = prepared.provider_config.validate_environment( + headers={}, + model=prepared.model, + api_key=prepared.api_key, + api_base=prepared.api_base, + litellm_params=prepared.litellm_params, + ) + assert headers["Ocp-Apim-Subscription-Key"] == "document-key" + + +def test_document_intelligence_explicit_connection_is_preserved(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AZURE_AI_API_KEY", "generic-key") + monkeypatch.setenv("AZURE_AI_API_BASE", "https://generic.example.com") + prepared: Final = _prepare_ocr_request( + model="azure_ai/doc-intelligence/prebuilt-layout", + document={"type": "document_url", "document_url": "https://example.com/file.pdf"}, + api_key="explicit-key", + api_base="https://document.example.com", + timeout=None, + custom_llm_provider=None, + extra_headers=None, + kwargs={"litellm_logging_obj": Mock()}, + ) + + assert prepared.api_key == "explicit-key" + assert prepared.api_base == "https://document.example.com" + + +def test_generic_azure_connection_still_applies_to_foundry_ocr(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AZURE_AI_API_KEY", "generic-key") + monkeypatch.setenv("AZURE_AI_API_BASE", "https://generic.example.com") + prepared: Final = _prepare_ocr_request( + model="azure_ai/mistral-document-ai-2505", + document={"type": "document_url", "document_url": "https://example.com/file.pdf"}, + api_key=None, + api_base=None, + timeout=None, + custom_llm_provider=None, + extra_headers=None, + kwargs={"litellm_logging_obj": Mock()}, + ) + + assert prepared.api_key == "generic-key" + assert prepared.api_base == "https://generic.example.com" diff --git a/tests/test_litellm/ocr/test_ocr_azure_document_intelligence_api_base.py b/tests/test_litellm/ocr/test_ocr_azure_document_intelligence_api_base.py deleted file mode 100644 index 460aff3e8d1..00000000000 --- a/tests/test_litellm/ocr/test_ocr_azure_document_intelligence_api_base.py +++ /dev/null @@ -1,73 +0,0 @@ -""" -Regression tests for Azure Document Intelligence api_base ownership in OCR. - -`azure_ai` exposes two OCR services on one provider; the `doc-intelligence` -sub-route must defer environment resolution to Rust, not accept the generic -`AZURE_AI_API_BASE` fallback that `get_llm_provider` injects. An explicitly -supplied api_base is still always honoured. -""" - -from litellm.llms.azure_ai.ocr.common_utils import ( - is_azure_document_intelligence_model, -) -from litellm.ocr.main import _prepare_ocr_request - -_DOC = {"type": "document_url", "document_url": "https://example.com/doc.pdf"} -_AZURE_AI_API_BASE = "https://generic-azure-ai.example.com" - - -class _FakeLogging: - def update_from_kwargs(self, **kwargs: object) -> None: - return None - - -def _prepare(model: str, api_base: str | None): - return _prepare_ocr_request( - model=model, - document=dict(_DOC), - api_key="test-key", - api_base=api_base, - timeout=None, - custom_llm_provider=None, - extra_headers=None, - kwargs={"litellm_logging_obj": _FakeLogging()}, - ) - - -class TestIsAzureDocumentIntelligenceModel: - def test_matches_doc_intelligence_route(self): - assert is_azure_document_intelligence_model("doc-intelligence/prebuilt-layout") - - def test_matches_documentintelligence_and_is_case_insensitive(self): - assert is_azure_document_intelligence_model("azure_ai/DocumentIntelligence/x") - - def test_does_not_match_mistral_route(self): - assert not is_azure_document_intelligence_model("mistral-document-ai-2505") - - -class TestDocIntelligenceApiBaseResolution: - def test_generic_azure_ai_base_does_not_hijack_doc_intelligence(self, monkeypatch): - """The generic Azure base must not overwrite Rust-owned DI resolution.""" - monkeypatch.setenv("AZURE_AI_API_BASE", _AZURE_AI_API_BASE) - monkeypatch.delenv("AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT", raising=False) - - prepared = _prepare("azure_ai/doc-intelligence/prebuilt-layout", None) - - assert prepared.api_base is None - - def test_explicit_api_base_is_honoured_for_doc_intelligence(self, monkeypatch): - """A caller-supplied api_base must always win, even for doc-intelligence.""" - monkeypatch.setenv("AZURE_AI_API_BASE", _AZURE_AI_API_BASE) - - custom = "https://my-di.cognitiveservices.azure.com" - prepared = _prepare("azure_ai/doc-intelligence/prebuilt-layout", custom) - - assert prepared.api_base == custom - - def test_generic_azure_ai_base_still_applies_to_mistral_ocr(self, monkeypatch): - """Non doc-intelligence azure_ai models keep using AZURE_AI_API_BASE.""" - monkeypatch.setenv("AZURE_AI_API_BASE", _AZURE_AI_API_BASE) - - prepared = _prepare("azure_ai/mistral-document-ai-2505", None) - - assert prepared.api_base == _AZURE_AI_API_BASE diff --git a/tests/test_litellm/ocr/test_ocr_file_input.py b/tests/test_litellm/ocr/test_ocr_file_input.py index feb98d14c03..3526d8c00d6 100644 --- a/tests/test_litellm/ocr/test_ocr_file_input.py +++ b/tests/test_litellm/ocr/test_ocr_file_input.py @@ -12,15 +12,32 @@ Tests that: import base64 import os import tempfile +from collections.abc import Generator from io import BytesIO from pathlib import Path -from unittest.mock import AsyncMock, MagicMock +from typing import Final +from unittest.mock import AsyncMock, MagicMock, Mock import orjson import pytest from starlette.datastructures import FormData -from litellm.ocr.main import convert_file_document_to_url_document, get_mime_type +from litellm.ocr.input import convert_file_document_to_url_document, get_mime_type + + +@pytest.fixture(autouse=True, params=["native", "disabled", "unavailable"]) +def document_runtime(request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch) -> Generator[None]: + from litellm.rust_bridge import bindings, configuration + + configuration.reset_rust_configuration() + monkeypatch.delenv("LITELLM_RUST", raising=False) + if request.param == "disabled": + monkeypatch.setenv("LITELLM_RUST", "0") + monkeypatch.setattr(bindings, "get_native_bridge", Mock(side_effect=AssertionError("Rust is disabled"))) + elif request.param == "unavailable": + monkeypatch.setattr(bindings, "get_native_bridge", lambda: None) + yield + configuration.reset_rust_configuration() class TestGetMimeType: @@ -480,3 +497,37 @@ class TestProxySecurityGuard: "data:application/pdf;base64," ) assert result["model"] == "mistral/mistral-ocr-latest" + + +@pytest.mark.asyncio +async def test_proxy_upload_stops_reading_at_size_limit() -> None: + from starlette.datastructures import UploadFile + + from litellm.ocr.input import get_max_file_bytes + from litellm.proxy.ocr_endpoints.endpoints import _parse_multipart_form + + limit: Final = get_max_file_bytes() + with tempfile.TemporaryFile() as stream: + stream.truncate(limit * 2) + upload: Final = UploadFile(file=stream, filename="large.pdf") + request: Final = MagicMock(form=AsyncMock(return_value=FormData({"file": upload}))) + with pytest.raises(ValueError, match="exceeds the size limit"): + await _parse_multipart_form(request) + assert stream.tell() == limit + 1 + + +@pytest.mark.asyncio +async def test_proxy_upload_filename_is_only_metadata(tmp_path: Path) -> None: + from starlette.datastructures import UploadFile + + from litellm.proxy.ocr_endpoints.endpoints import _parse_multipart_form + + secret: Final = tmp_path / "secret.pdf" + secret.write_bytes(b"server secret") + upload: Final = UploadFile(file=BytesIO(b"uploaded bytes"), filename=str(secret)) + request: Final = MagicMock(form=AsyncMock(return_value=FormData({"file": upload}))) + result: Final = await _parse_multipart_form(request) + assert result["document"] == { + "type": "document_url", + "document_url": "data:application/pdf;base64,dXBsb2FkZWQgYnl0ZXM=", + } diff --git a/tests/test_litellm/ocr/test_ocr_native_format.py b/tests/test_litellm/ocr/test_ocr_native_format.py index 46e9a4d3729..4ad556f6941 100644 --- a/tests/test_litellm/ocr/test_ocr_native_format.py +++ b/tests/test_litellm/ocr/test_ocr_native_format.py @@ -2,37 +2,7 @@ Tests for the OCR `req_format` option in the SDK request path. """ -import pytest - -import litellm from litellm.rust_bridge import ocr as rust_ocr_bridge -from litellm.rust_bridge.ocr import LiteLLMOcrRequest - -DOCUMENT = {"type": "document_url", "document_url": "https://example.com/doc.pdf"} - - -def _request( - optional_params: dict[str, object], model: str = "azure_ai/doc-intelligence/prebuilt-layout" -) -> LiteLLMOcrRequest: - return LiteLLMOcrRequest( - model=model, - document=DOCUMENT, - api_key="fake-key", - api_base=None, - custom_llm_provider=None, - extra_headers=None, - timeout=60.0, - kwargs=optional_params, - ) - - -@pytest.mark.parametrize("optional_params", [{}, {"req_format": "litellm"}]) -def test_rust_ocr_serves_default_format(optional_params): - assert rust_ocr_bridge.supported(_request(optional_params)) is True - - -def test_rust_ocr_serves_native_format_for_document_intelligence(): - assert rust_ocr_bridge.supported(_request({"req_format": "native"})) is True def test_rust_ocr_response_retains_provider_native_response(): @@ -50,34 +20,3 @@ def test_rust_ocr_response_retains_provider_native_response(): assert response.get_provider_native_response() == provider_response assert response.model_dump().get("provider_native_response") is None - - -@pytest.mark.parametrize("model", ["cohere/cohere-parse", "azure_ai/cohere-parse"]) -def test_rust_ocr_skipped_for_unsupported_models(model): - assert rust_ocr_bridge.supported(_request({}, model)) is False - - -@pytest.mark.asyncio -async def test_native_format_rejected_for_provider_without_support_as_bad_request(): - with pytest.raises(litellm.BadRequestError, match="not supported for provider") as exc_info: - await litellm.aocr( - model="mistral/mistral-ocr-latest", - document=DOCUMENT, - api_key="fake-key", - req_format="native", - ) - - assert exc_info.value.status_code == 400 - - -@pytest.mark.asyncio -async def test_unknown_format_rejected_for_provider_without_support_as_bad_request(): - with pytest.raises(litellm.BadRequestError, match="Invalid `req_format`") as exc_info: - await litellm.aocr( - model="mistral/mistral-ocr-latest", - document=DOCUMENT, - api_key="fake-key", - req_format="raw", - ) - - assert exc_info.value.status_code == 400 diff --git a/tests/test_litellm/ocr/test_rust_bridge.py b/tests/test_litellm/ocr/test_rust_bridge.py deleted file mode 100644 index dbb4f822d0b..00000000000 --- a/tests/test_litellm/ocr/test_rust_bridge.py +++ /dev/null @@ -1,1161 +0,0 @@ -"""Tests for the optional Rust-backed OCR path.""" - -import builtins -import importlib -import types - -import httpx -import pytest - -import litellm -from litellm.llms.base_llm.chat.transformation import BaseLLMException -from litellm.llms.base_llm.ocr.transformation import OCRResponse -from litellm.rust_bridge import configuration - -# `litellm/__init__.py` does `from .ocr.main import *`, which binds the `ocr` -# function onto `litellm.ocr` and shadows the submodule, so import the modules -# explicitly via importlib rather than attribute traversal. -ocr_main = importlib.import_module("litellm.ocr.main") -rust_bridge = importlib.import_module("litellm.rust_bridge.ocr") -rust_bridge_bindings = importlib.import_module("litellm.rust_bridge.bindings") -rust_bridge_loader = importlib.import_module("litellm.rust_bridge.loader") - -MODEL = "mistral/mistral-ocr-latest" -DOCUMENT: dict[str, object] = { - "type": "document_url", - "document_url": "https://example.com/doc.pdf", -} - -FAKE_OCR_RESPONSE: dict[str, object] = { - "pages": [{"index": 0, "markdown": "hello world"}], - "model": "mistral-ocr-2505-completion", - "document_annotation": None, - "usage_info": {"pages_processed": 1}, - "object": "ocr", -} - - -class CapturedException(Exception): - pass - - -class RustUpstreamError(Exception): - pass - - -class RecordingBridge: - """A fake ``RustOcr`` callable that records the args it was handed.""" - - def __init__(self) -> None: - self.calls: list[dict[str, object]] = [] - - def __call__( - self, - model: str, - document: dict[str, object], - api_key: str | None, - api_base: str | None, - custom_llm_provider: str | None, - extra_headers: dict[str, object] | None, - optional_params: dict[str, object], - input_sources: dict[str, str], - timeout_seconds: float | None, - ) -> dict[str, object]: - self.calls.append( - { - "model": model, - "document": document, - "api_key": api_key, - "api_base": api_base, - "custom_llm_provider": custom_llm_provider, - "extra_headers": extra_headers, - "optional_params": optional_params, - "input_sources": input_sources, - "timeout_seconds": timeout_seconds, - } - ) - return dict(FAKE_OCR_RESPONSE) - - -class RecordingAsyncBridge: - """A fake async ``RustAocr`` callable that records the args it was handed.""" - - def __init__(self) -> None: - self.calls: list[dict[str, object]] = [] - - async def __call__( - self, - model: str, - document: dict[str, object], - api_key: str | None, - api_base: str | None, - custom_llm_provider: str | None, - extra_headers: dict[str, object] | None, - optional_params: dict[str, object], - input_sources: dict[str, str], - timeout_seconds: float | None, - ) -> dict[str, object]: - self.calls.append( - { - "model": model, - "document": document, - "api_key": api_key, - "api_base": api_base, - "custom_llm_provider": custom_llm_provider, - "extra_headers": extra_headers, - "optional_params": optional_params, - "input_sources": input_sources, - "timeout_seconds": timeout_seconds, - } - ) - return dict(FAKE_OCR_RESPONSE) - - -class RaisingBridge: - def __call__( - self, - model: str, - document: dict[str, object], - api_key: str | None, - api_base: str | None, - custom_llm_provider: str | None, - extra_headers: dict[str, object] | None, - optional_params: dict[str, object], - input_sources: dict[str, str], - timeout_seconds: float | None, - ) -> dict[str, object]: - raise RuntimeError("bridge failed") - - -class RaisingAsyncBridge: - async def __call__( - self, - model: str, - document: dict[str, object], - api_key: str | None, - api_base: str | None, - custom_llm_provider: str | None, - extra_headers: dict[str, object] | None, - optional_params: dict[str, object], - input_sources: dict[str, str], - timeout_seconds: float | None, - ) -> dict[str, object]: - raise RuntimeError("bridge failed") - - -class RecordingLogging: - """A spy standing in for ``LiteLLMLoggingObj`` to capture ``pre_call``.""" - - def __init__(self) -> None: - self.pre_call_kwargs: dict[str, object] | None = None - - def update_from_kwargs(self, **kwargs: object) -> None: - self.update_kwargs = kwargs - - def pre_call( - self, - *, - input: str, - api_key: str | None, - additional_args: dict[str, object], - ) -> None: - self.pre_call_kwargs = { - "input": input, - "api_key": api_key, - "additional_args": additional_args, - } - - -def build_request( - *, - logging_obj: RecordingLogging | None = None, - model: str = "mistral-ocr-latest", - document: dict[str, object] = DOCUMENT, - api_key: str | None = "sk-test", - api_base: str | None = None, - custom_llm_provider: str | None = "mistral", - extra_headers: dict[str, object] | None = None, - optional_params: dict[str, object] | None = None, - litellm_params: dict[str, object] | None = None, - timeout: float | httpx.Timeout | None = 12.5, -) -> rust_bridge.LiteLLMOcrRequest: - return rust_bridge.LiteLLMOcrRequest( - model=model, - document=document, - api_key=api_key, - api_base=api_base, - custom_llm_provider=custom_llm_provider, - extra_headers=extra_headers, - timeout=timeout, - kwargs={ - **(optional_params or {}), - **(litellm_params or {}), - "litellm_logging_obj": logging_obj or RecordingLogging(), - }, - ) - - -@pytest.fixture(autouse=True) -def _reset_rust_flag(): - """Keep the global toggle isolated between tests.""" - rust_bridge._OCR.reset() - rust_bridge._AOCR.reset() - configuration.reset_rust_configuration() - rust_bridge_loader._cached_bridge = rust_bridge_loader._BRIDGE_SENTINEL - yield - rust_bridge._OCR.reset() - rust_bridge._AOCR.reset() - configuration.reset_rust_configuration() - rust_bridge_loader._cached_bridge = rust_bridge_loader._BRIDGE_SENTINEL - - -@pytest.fixture -def fake_bridge(): - """Enable the Rust path with an injected recording bridge (no native wheel).""" - bridge = RecordingBridge() - litellm.rust(True) - rust_bridge._OCR.override(bridge) - return bridge - - -@pytest.fixture -def fake_async_bridge(): - """Enable the async Rust path with an injected recording bridge.""" - bridge = RecordingAsyncBridge() - litellm.rust(True) - rust_bridge._AOCR.override(bridge) - return bridge - - -def test_load_rust_ocr_returns_injected_impl(): - bridge = RecordingBridge() - litellm.rust(True) - rust_bridge._OCR.override(bridge) - assert rust_bridge.load_rust_ocr() is bridge - - -def test_native_bridge_loader_returns_none_when_extension_absent(monkeypatch): - real_import = builtins.__import__ - - def fake_import(name, globals=None, locals=None, fromlist=(), level=0): - if name == "litellm.rust_bridge" and "_native" in fromlist: - raise ImportError - return real_import(name, globals, locals, fromlist, level) - - monkeypatch.setattr(builtins, "__import__", fake_import) - - assert rust_bridge_loader.get_native_bridge() is None - - -def test_native_bridge_loader_caches_absent_extension(monkeypatch): - real_import = builtins.__import__ - attempts = 0 - - def fake_import(name, globals=None, locals=None, fromlist=(), level=0): - nonlocal attempts - if name == "litellm.rust_bridge" and "_native" in fromlist: - attempts += 1 - raise ImportError - return real_import(name, globals, locals, fromlist, level) - - monkeypatch.setattr(builtins, "__import__", fake_import) - - assert rust_bridge_loader.get_native_bridge() is None - assert rust_bridge_loader.get_native_bridge() is None - assert attempts == 1 - - -def test_native_bridge_loader_reset_forces_relookup(monkeypatch): - real_import = builtins.__import__ - attempts = 0 - - def fake_import(name, globals=None, locals=None, fromlist=(), level=0): - nonlocal attempts - if name == "litellm.rust_bridge" and "_native" in fromlist: - attempts += 1 - raise ImportError - return real_import(name, globals, locals, fromlist, level) - - monkeypatch.setattr(builtins, "__import__", fake_import) - - assert rust_bridge_loader.get_native_bridge() is None - rust_bridge_loader.reset_native_bridge_cache() - assert rust_bridge_loader.get_native_bridge() is None - assert attempts == 2 - - -def test_native_bridge_available_reflects_loader(monkeypatch): - fake_module = types.ModuleType("litellm.rust_bridge._native") - monkeypatch.setattr(rust_bridge_loader, "get_native_bridge", lambda: fake_module) - - assert rust_bridge_loader.native_bridge_available() is True - - -def test_load_rust_aocr_returns_injected_impl(): - bridge = RecordingAsyncBridge() - litellm.rust(True) - rust_bridge._AOCR.override(bridge) - assert rust_bridge.load_rust_aocr() is bridge - - -def test_toggle_without_ocr_arg_preserves_injected_impl(): - """The public flag must not clobber an internal test binding.""" - bridge = RecordingBridge() - async_bridge = RecordingAsyncBridge() - litellm.rust(True) - rust_bridge._OCR.override(bridge) - rust_bridge._AOCR.override(async_bridge) - - litellm.rust(False) - assert rust_bridge.load_rust_ocr() is bridge - assert rust_bridge.load_rust_aocr() is async_bridge - litellm.rust(True) - assert rust_bridge.load_rust_ocr() is bridge - assert rust_bridge.load_rust_aocr() is async_bridge - - -def test_explicit_ocr_none_clears_injected_impl(monkeypatch): - monkeypatch.setattr( - rust_bridge_bindings, - "get_native_bridge", - lambda: None, - ) - bridge = RecordingBridge() - async_bridge = RecordingAsyncBridge() - litellm.rust(True) - rust_bridge._OCR.override(bridge) - rust_bridge._AOCR.override(async_bridge) - - rust_bridge._OCR.override(None) - rust_bridge._AOCR.override(None) - assert rust_bridge.load_rust_ocr() is None - assert rust_bridge.load_rust_aocr() is None - - -def test_load_rust_ocr_none_when_extension_absent(monkeypatch): - """With no injected impl and no compiled wheel, the loader returns None so the - caller degrades to the Python path instead of raising ImportError.""" - monkeypatch.setattr( - rust_bridge_bindings, - "get_native_bridge", - lambda: None, - ) - litellm.rust(True) # no impl injected; extension isn't built in CI - assert rust_bridge.load_rust_ocr() is None - assert rust_bridge.load_rust_aocr() is None - - -def test_load_rust_ocr_uses_compiled_extension(monkeypatch): - """With no injected impl but a packaged ``litellm.rust_bridge._native`` importable, - the loader returns the extension's ``ocr`` callable. The native wheel isn't - built in CI, so stand in a fake module via the bridge loader.""" - fake_module = types.ModuleType("litellm.rust_bridge._native") - fake_module.ocr = lambda **kwargs: dict(FAKE_OCR_RESPONSE) # type: ignore[attr-defined] - fake_module.aocr = lambda **kwargs: dict(FAKE_OCR_RESPONSE) # type: ignore[attr-defined] - monkeypatch.setattr( - rust_bridge_bindings, - "get_native_bridge", - lambda: fake_module, - ) - - litellm.rust(True) # enabled, no impl injected -> import the extension - assert rust_bridge.load_rust_ocr() is fake_module.ocr - assert rust_bridge.load_rust_aocr() is fake_module.aocr - - -def test_timeout_to_seconds_handles_float_timeout_and_none(): - assert rust_bridge._timeout_to_seconds(12.5) == 12.5 - assert rust_bridge._timeout_to_seconds(None) is None - assert rust_bridge._timeout_to_seconds(httpx.Timeout(30.0, read=42.0)) == 42.0 - - -def test_bridge_wrapper_forwards_prepared_args_and_wraps_response(): - bridge = RecordingBridge() - - litellm.rust(True) - - rust_bridge._OCR.override(bridge) - response = rust_bridge.ocr( - model="mistral-ocr-latest", - document=DOCUMENT, - api_key="sk-test", - api_base="https://proxy.internal", - custom_llm_provider="mistral", - extra_headers={"Authorization": "Bearer sk-test", "x-trace-id": "trace-1"}, - optional_params={"include_image_base64": True, "pages": [0]}, - timeout=12.5, - ) - - assert response == FAKE_OCR_RESPONSE - call = bridge.calls[0] - assert call == { - "model": "mistral-ocr-latest", - "document": DOCUMENT, - "api_key": "sk-test", - "api_base": "https://proxy.internal", - "custom_llm_provider": "mistral", - "extra_headers": { - "Authorization": "Bearer sk-test", - "x-trace-id": "trace-1", - }, - "optional_params": {"include_image_base64": True, "pages": [0]}, - "input_sources": {}, - "timeout_seconds": 12.5, - } - - -@pytest.mark.asyncio -async def test_bridge_wrapper_forwards_prepared_async_args_and_wraps_response(): - bridge = RecordingAsyncBridge() - - litellm.rust(True) - - rust_bridge._AOCR.override(bridge) - response = await rust_bridge.aocr( - model="mistral-ocr-maas", - document=DOCUMENT, - api_key=None, - api_base=None, - custom_llm_provider="vertex_ai", - extra_headers=None, - optional_params={"vertex_project": "project-1"}, - timeout=httpx.Timeout(30.0, read=42.0), - ) - - assert response == FAKE_OCR_RESPONSE - assert bridge.calls[0] == { - "model": "mistral-ocr-maas", - "document": DOCUMENT, - "api_key": None, - "api_base": None, - "custom_llm_provider": "vertex_ai", - "extra_headers": None, - "optional_params": {"vertex_project": "project-1"}, - "input_sources": {}, - "timeout_seconds": 42.0, - } - - -def test_run_rust_ocr_prepares_request_and_wraps_response(): - bridge = RecordingBridge() - logging_obj = RecordingLogging() - litellm.rust(True) - rust_bridge._OCR.override(bridge) - - response = ocr_main._run_rust_ocr( - request=build_request( - logging_obj=logging_obj, - api_base="https://proxy.internal", - extra_headers={"x-trace-id": "trace-1"}, - optional_params={"include_image_base64": True}, - timeout=12.5, - ), - resolve_api_key=lambda _name: None, - ) - - assert isinstance(response, OCRResponse) - assert response.pages[0].markdown == "hello world" - assert bridge.calls[0] == { - "model": "mistral-ocr-latest", - "document": DOCUMENT, - "api_key": "sk-test", - "api_base": "https://proxy.internal", - "custom_llm_provider": "mistral", - "extra_headers": { - "x-trace-id": "trace-1", - }, - "optional_params": {"include_image_base64": True}, - "input_sources": {}, - "timeout_seconds": 12.5, - } - - -def test_rust_upstream_error_uses_ocr_provider_error_mapping(): - error = RustUpstreamError(400, '{"message":"invalid model"}') - - mapped = ocr_main._map_rust_ocr_error( - error, - build_request(), - (RuntimeError, RustUpstreamError), - ) - - assert isinstance(mapped, BaseLLMException) - assert mapped.status_code == 400 - assert mapped.message == '{"message":"invalid model"}' - - -def test_run_rust_ocr_resolves_key_via_secret_manager_when_missing(): - bridge = RecordingBridge() - litellm.rust(True) - rust_bridge._OCR.override(bridge) - - ocr_main._run_rust_ocr( - request=build_request(api_key=None, timeout=None), - resolve_api_key=lambda name: "sk-from-vault" if name == "MISTRAL_API_KEY" else None, - ) - - assert bridge.calls[0]["api_key"] == "sk-from-vault" - - -def test_run_rust_ocr_prefers_explicit_key_over_resolver(): - bridge = RecordingBridge() - litellm.rust(True) - rust_bridge._OCR.override(bridge) - - def _resolver(name: str) -> str | None: - raise AssertionError(f"resolver should not be called for {name}") - - ocr_main._run_rust_ocr( - request=build_request( - api_key="sk-explicit", - timeout=None, - ), - resolve_api_key=_resolver, - ) - - assert bridge.calls[0]["api_key"] == "sk-explicit" - - -def test_run_rust_ocr_uses_mistral_secret_manager_without_provider_config(): - bridge = RecordingBridge() - resolver_calls = [] - litellm.rust(True) - rust_bridge._OCR.override(bridge) - - def _resolver(name): - resolver_calls.append(name) - return "sk-provider-env" - - ocr_main._run_rust_ocr( - request=build_request( - model="mistral-ocr-latest", - api_key=None, - timeout=None, - ), - resolve_api_key=_resolver, - ) - - assert resolver_calls == ["MISTRAL_API_KEY"] - assert bridge.calls[0]["api_key"] == "sk-provider-env" - - -def test_prepare_rust_ocr_call_forwards_vertex_routing_metadata(): - bridge = RecordingBridge() - litellm.rust(True) - rust_bridge._OCR.override(bridge) - - ocr_main._run_rust_ocr( - request=build_request( - custom_llm_provider="vertex_ai", - model="mistral-ocr-maas", - litellm_params={ - "vertex_project": "project-1", - "vertex_location": "us-central1", - "vertex_credentials": "redacted", - }, - optional_params={"include_image_base64": True}, - timeout=None, - ), - resolve_api_key=lambda _name: None, - ) - - assert bridge.calls[0]["optional_params"] == { - "include_image_base64": True, - "vertex_project": "project-1", - "vertex_location": "us-central1", - "vertex_credentials": "redacted", - } - - -def test_prepare_rust_ocr_call_resolves_vertex_routing_metadata_from_secret_manager(): - bridge = RecordingBridge() - litellm.rust(True) - rust_bridge._OCR.override(bridge) - - def _resolver(name: str) -> str | None: - return { - "VERTEXAI_PROJECT": "project-from-secret", - "VERTEXAI_LOCATION": "us-east5", - "VERTEXAI_CREDENTIALS": "credentials-from-secret", - }.get(name) - - ocr_main._run_rust_ocr( - request=build_request( - custom_llm_provider="vertex_ai", - model="mistral-ocr-maas", - timeout=None, - ), - resolve_api_key=_resolver, - ) - - assert bridge.calls[0]["optional_params"]["vertex_project"] == "project-from-secret" - assert bridge.calls[0]["optional_params"]["vertex_location"] == "us-east5" - assert bridge.calls[0]["optional_params"]["vertex_credentials"] == "credentials-from-secret" - - -def test_prepare_rust_ocr_call_defers_azure_environment_resolution_to_rust(): - bridge = RecordingBridge() - litellm.rust(True) - rust_bridge._OCR.override(bridge) - - ocr_main._run_rust_ocr( - request=build_request( - custom_llm_provider="azure_ai", - model="pixtral-12b-2409", - api_key=None, - api_base=None, - timeout=None, - ), - resolve_api_key=lambda name: pytest.fail(f"Python resolved Azure secret {name}"), - ) - - assert bridge.calls[0]["api_base"] is None - assert bridge.calls[0]["api_key"] is None - assert bridge.calls[0]["extra_headers"] is None - - -def test_prepare_rust_ocr_call_defers_document_intelligence_environment_to_rust(): - bridge = RecordingBridge() - litellm.rust(True) - rust_bridge._OCR.override(bridge) - - ocr_main._run_rust_ocr( - request=build_request( - custom_llm_provider="azure_ai", - model="doc-intelligence/prebuilt-layout", - api_base=None, - timeout=None, - ), - resolve_api_key=lambda name: pytest.fail(f"Python resolved Azure secret {name}"), - ) - - assert bridge.calls[0]["api_base"] is None - - -def test_prepare_rust_ocr_call_forwards_raw_azure_auth_inputs(): - bridge = RecordingBridge() - litellm.rust(True) - rust_bridge._OCR.override(bridge) - - ocr_main._run_rust_ocr( - request=build_request( - custom_llm_provider="azure_ai", - model="pixtral-12b-2409", - api_key=None, - api_base="https://azure.example.com", - extra_headers={"x-trace-id": "trace-1"}, - litellm_params={ - "azure_ad_token": "entra-token", - "tenant_id": "tenant", - "client_id": "client", - "client_secret": "secret", - "azure_scope": "scope", - "azure_authority_host": "https://login.example.com", - "azure_credential": "ClientSecretCredential", - "azure_federated_token_file": "/token", - }, - timeout=None, - ), - resolve_api_key=lambda name: pytest.fail(f"Python resolved Azure secret {name}"), - ) - - call = bridge.calls[0] - assert call["api_key"] is None - assert call["api_base"] == "https://azure.example.com" - assert call["extra_headers"] == {"x-trace-id": "trace-1"} - assert call["optional_params"] == { - "azure_ad_token": "entra-token", - "tenant_id": "tenant", - "client_id": "client", - "client_secret": "secret", - "azure_scope": "scope", - "azure_authority_host": "https://login.example.com", - "azure_credential": "ClientSecretCredential", - "azure_federated_token_file": "/token", - } - assert call["input_sources"] == {} - - -def test_prepare_rust_ocr_call_preserves_proxy_input_sources(): - bridge = RecordingBridge() - litellm.rust(True) - rust_bridge._OCR.override(bridge) - request_values = { - "tenant_id": "tenant", - "client_id": "client", - "client_secret": "secret", - "azure_authority_host": "https://login.example.com", - "api_base": "https://azure.example.com", - } - - ocr_main._run_rust_ocr( - request=build_request( - custom_llm_provider="azure_ai", - model="pixtral-12b-2409", - api_key="request-key", - api_base="https://azure.example.com", - litellm_params={ - "tenant_id": "tenant", - "client_id": "client", - "client_secret": "secret", - "azure_authority_host": "https://login.example.com", - "proxy_server_request": {"body": request_values, "credential_fields": ("api_key",)}, - }, - ), - resolve_api_key=lambda _name: None, - ) - - assert bridge.calls[0]["input_sources"] == { - **{name: "request" for name in request_values}, - "api_key": "request", - } - - marshaled = rust_bridge._marshal( - build_request( - custom_llm_provider="azure_ai", - model="pixtral-12b-2409", - api_key="request-key", - api_base="https://azure.example.com", - litellm_params={ - "proxy_server_request": { - "body": {"api_base": "https://azure.example.com"}, - "credential_fields": ("api_key",), - } - }, - ), - lambda _name: None, - lambda document: document, - ) - assert marshaled.input_sources == {"api_base": "request", "api_key": "request"} - - -def test_rust_ocr_logging_redacts_azure_credentials(): - bridge = RecordingBridge() - logging_obj = RecordingLogging() - litellm.rust(True) - rust_bridge._OCR.override(bridge) - - ocr_main._run_rust_ocr( - request=build_request( - logging_obj=logging_obj, - custom_llm_provider="azure_ai", - model="pixtral-12b-2409", - api_key=None, - litellm_params={"azure_ad_token": "token", "client_secret": "secret"}, - ), - resolve_api_key=lambda _name: None, - ) - - assert logging_obj.update_kwargs["optional_params"] == { - "azure_ad_token": "****", - "client_secret": "****", - } - assert logging_obj.pre_call_kwargs is not None - additional_args = logging_obj.pre_call_kwargs["additional_args"] - assert isinstance(additional_args, dict) - complete_input = additional_args["complete_input_dict"] - assert isinstance(complete_input, dict) - assert complete_input["azure_ad_token"] == "****" - assert complete_input["client_secret"] == "****" - - -def test_rust_eligibility_rejects_python_only_azure_auth_modes(): - for params in ( - {"azure_ad_token_provider": lambda: "token"}, - {"azure_username": "user"}, - {"azure_password": "password"}, - ): - assert not ocr_main._rust_ocr_supported( - build_request( - custom_llm_provider="azure_ai", - model="pixtral-12b-2409", - litellm_params=params, - ) - ) - - -def test_prepare_rust_ocr_call_forwards_global_azure_refresh(monkeypatch: pytest.MonkeyPatch): - bridge = RecordingBridge() - litellm.rust(True) - rust_bridge._OCR.override(bridge) - monkeypatch.setattr(litellm, "enable_azure_ad_token_refresh", True) - - ocr_main._run_rust_ocr( - request=build_request( - custom_llm_provider="azure_ai", - model="pixtral-12b-2409", - api_key=None, - api_base="https://azure.example.com", - litellm_params={"proxy_server_request": {"body": {"enable_azure_ad_token_refresh": True}}}, - timeout=None, - ), - resolve_api_key=lambda _name: None, - ) - - assert bridge.calls[0]["optional_params"] == {"enable_azure_ad_token_refresh": True} - assert bridge.calls[0]["input_sources"] == {"enable_azure_ad_token_refresh": "deployment"} - - -def test_run_rust_ocr_runs_pre_call_logging(): - logging_obj = RecordingLogging() - bridge = RecordingBridge() - litellm.rust(True) - rust_bridge._OCR.override(bridge) - - ocr_main._run_rust_ocr( - request=build_request( - logging_obj=logging_obj, - api_base="https://api.mistral.ai/v1", - extra_headers={"x-trace-id": "trace-1"}, - optional_params={"include_image_base64": True}, - timeout=None, - ), - resolve_api_key=lambda _name: None, - ) - - assert logging_obj.pre_call_kwargs is not None - assert logging_obj.pre_call_kwargs["input"] == "OCR document processing" - additional_args = logging_obj.pre_call_kwargs["additional_args"] - complete_input = additional_args["complete_input_dict"] - assert complete_input["document"] == DOCUMENT - assert complete_input["include_image_base64"] is True - assert additional_args["api_base"] == "https://api.mistral.ai/v1" - assert additional_args["headers"] == { - "x-trace-id": "trace-1", - } - - -def test_ocr_routes_to_rust_when_enabled(fake_bridge): - response = litellm.ocr( - model=MODEL, - document=DOCUMENT, - api_key="sk-test", - extra_headers={"x-trace-id": "trace-1"}, - include_image_base64=True, - ) - - assert isinstance(response, OCRResponse) - assert response.pages[0].markdown == "hello world" - assert len(fake_bridge.calls) == 1 - call = fake_bridge.calls[0] - assert call["model"] == MODEL - assert call["document"] == DOCUMENT - assert call["api_key"] == "sk-test" - assert call["custom_llm_provider"] is None - assert call["extra_headers"] == { - "x-trace-id": "trace-1", - } - assert call["optional_params"].get("include_image_base64") is True - - -def test_ocr_routes_azure_ai_to_rust_when_enabled(fake_bridge): - response = litellm.ocr( - model="azure_ai/pixtral-12b-2409", - document=DOCUMENT, - api_key="sk-test", - api_base="https://example.services.ai.azure.com", - ) - - assert isinstance(response, OCRResponse) - assert len(fake_bridge.calls) == 1 - assert fake_bridge.calls[0]["model"] == "azure_ai/pixtral-12b-2409" - assert fake_bridge.calls[0]["custom_llm_provider"] is None - assert fake_bridge.calls[0]["extra_headers"] is None - - -def test_ocr_routes_azure_entra_inputs_to_rust_without_python_auth(fake_bridge): - response = litellm.ocr( - model="azure_ai/pixtral-12b-2409", - document=DOCUMENT, - api_base="https://example.services.ai.azure.com", - azure_ad_token="entra-token", - tenant_id="tenant", - client_id="client", - ) - - assert isinstance(response, OCRResponse) - assert fake_bridge.calls[0]["api_key"] is None - assert fake_bridge.calls[0]["extra_headers"] is None - assert fake_bridge.calls[0]["optional_params"] == { - "azure_ad_token": "entra-token", - "tenant_id": "tenant", - "client_id": "client", - } - - -def test_ocr_rust_path_converts_file_document_before_bridge(fake_bridge): - response = litellm.ocr( - model=MODEL, - document={"type": "file", "file": b"%PDF-1.4", "mime_type": "application/pdf"}, - api_key="sk-test", - ) - - assert isinstance(response, OCRResponse) - document = fake_bridge.calls[0]["document"] - assert document["type"] == "document_url" - assert document["document_url"].startswith("data:application/pdf;base64,") - - -def test_ocr_exception_type_uses_resolved_provider_context( - monkeypatch: pytest.MonkeyPatch, -): - captured: dict[str, object] = {} - - def fake_exception_type(**kwargs: object) -> CapturedException: - captured.update(kwargs) - return CapturedException("wrapped") - - monkeypatch.setattr(ocr_main.litellm, "exception_type", fake_exception_type) - litellm.rust(True) - rust_bridge._OCR.override(RaisingBridge()) - - with pytest.raises(CapturedException): - litellm.ocr(model=MODEL, document=DOCUMENT, api_key="sk-test") - - assert captured["model"] == "mistral-ocr-latest" - assert captured["custom_llm_provider"] == "mistral" - - -@pytest.mark.asyncio -async def test_aocr_routes_to_async_rust_when_enabled(fake_async_bridge): - response = await litellm.aocr( - model=MODEL, - document=DOCUMENT, - api_key="sk-test", - extra_headers={"x-trace-id": "trace-1"}, - include_image_base64=True, - ) - - assert isinstance(response, OCRResponse) - assert response.pages[0].markdown == "hello world" - assert len(fake_async_bridge.calls) == 1 - call = fake_async_bridge.calls[0] - assert call["model"] == MODEL - assert call["document"] == DOCUMENT - assert call["api_key"] == "sk-test" - assert call["custom_llm_provider"] is None - assert call["extra_headers"] == { - "x-trace-id": "trace-1", - } - assert call["optional_params"].get("include_image_base64") is True - - -@pytest.mark.asyncio -async def test_aocr_exception_type_uses_resolved_provider_context( - monkeypatch: pytest.MonkeyPatch, -): - captured: dict[str, object] = {} - - def fake_exception_type(**kwargs: object) -> CapturedException: - captured.update(kwargs) - return CapturedException("wrapped") - - monkeypatch.setattr(ocr_main.litellm, "exception_type", fake_exception_type) - litellm.rust(True) - rust_bridge._AOCR.override(RaisingAsyncBridge()) - - with pytest.raises(CapturedException): - await litellm.aocr(model=MODEL, document=DOCUMENT, api_key="sk-test") - - assert captured["model"] == "mistral-ocr-latest" - assert captured["custom_llm_provider"] == "mistral" - - -def test_ocr_forwards_timeout_to_rust(fake_bridge): - """Caller-supplied timeout must flow into the Rust bridge so the fixed 600s - client ceiling doesn't silently override shorter deadlines.""" - litellm.ocr(model=MODEL, document=DOCUMENT, api_key="sk-test", timeout=12.5) - - assert fake_bridge.calls[0]["timeout_seconds"] == 12.5 - - -def test_ocr_passes_default_request_timeout_to_rust(fake_bridge): - litellm.ocr(model=MODEL, document=DOCUMENT, api_key="sk-test") - - from litellm.constants import request_timeout - - assert fake_bridge.calls[0]["timeout_seconds"] == float(request_timeout) - - -def test_ocr_does_not_route_to_rust_when_disabled(): - """With the flag off, the bridge must not be consulted even if an impl exists.""" - bridge = RecordingBridge() - litellm.rust(False) - rust_bridge._OCR.override(bridge) - # The impl stays available for injection, but the disabled flag gates usage, - # so ocr() never reaches the Rust path (asserted via the enabled-path test). - assert bridge.calls == [] - - -def test_ocr_falls_back_to_python_when_bridge_unavailable(monkeypatch): - """Rust enabled but no bridge available (no injected impl, no compiled wheel): - ocr() must degrade to the Python HTTP handler instead of raising.""" - monkeypatch.setattr(rust_bridge, "load_rust_ocr", lambda: None) - litellm.rust(True) # enabled, but load_rust_ocr() returns None in CI - - captured = {} - - def fake_handler_ocr(**kwargs): - captured["called"] = True - return OCRResponse(pages=[], model="mistral-ocr-latest", object="ocr") - - monkeypatch.setattr(ocr_main.base_llm_http_handler, "ocr", fake_handler_ocr) - - response = litellm.ocr(model=MODEL, document=DOCUMENT, api_key="sk-test") - - assert captured.get("called") is True # Python path was used - assert isinstance(response, OCRResponse) - - -def test_ocr_provider_configs_expose_api_key_env_vars(): - from litellm.llms.azure_ai.ocr.document_intelligence.transformation import ( - AzureDocumentIntelligenceOCRConfig, - ) - from litellm.llms.azure_ai.ocr.transformation import AzureAIOCRConfig - from litellm.llms.base_llm.ocr.transformation import BaseOCRConfig - from litellm.llms.mistral.ocr.transformation import MistralOCRConfig - from litellm.llms.vertex_ai.ocr.deepseek_transformation import ( - VertexAIDeepSeekOCRConfig, - ) - from litellm.llms.vertex_ai.ocr.transformation import VertexAIOCRConfig - - assert BaseOCRConfig().get_api_key_env_var() is None - assert MistralOCRConfig().get_api_key_env_var() == "MISTRAL_API_KEY" - assert AzureAIOCRConfig().get_api_key_env_var() == "AZURE_AI_API_KEY" - assert AzureDocumentIntelligenceOCRConfig().get_api_key_env_var() == "AZURE_DOCUMENT_INTELLIGENCE_API_KEY" - assert VertexAIOCRConfig().get_api_key_env_var() == "VERTEX_AI_API_KEY" - assert VertexAIDeepSeekOCRConfig().get_api_key_env_var() == "VERTEX_AI_API_KEY" - - -@pytest.mark.parametrize("asynchronous", [False, True]) -@pytest.mark.asyncio -async def test_rust_receives_unmapped_azure_options(asynchronous, fake_bridge, fake_async_bridge): - from typing import Final - - arguments: Final = { - "model": "azure_ai/doc-intelligence/prebuilt-layout", - "document": DOCUMENT, - "api_key": "test-key", - "pages": [0, 2], - "features": ["languages", "style"], - "provider_extension": {"enabled": True}, - } - if asynchronous: - await litellm.aocr(**arguments) - else: - litellm.ocr(**arguments) - call: Final = (fake_async_bridge if asynchronous else fake_bridge).calls[0] - assert call["model"] == arguments["model"] - assert call["custom_llm_provider"] is None - assert call["extra_headers"] is None - assert call["optional_params"] == { - "pages": [0, 2], - "features": ["languages", "style"], - "provider_extension": {"enabled": True}, - } - - -@pytest.mark.parametrize("enabled", [False, True]) -@pytest.mark.asyncio -async def test_python_fallback_maps_original_options_once(enabled, monkeypatch): - from io import BytesIO - from typing import Final - - class PythonHandler: - def __init__(self): - self.calls = [] - - def ocr(self, **kwargs): - self.calls.append(kwargs) - return OCRResponse(pages=[], model=kwargs["model"]) - - handler: Final = PythonHandler() - monkeypatch.setattr(ocr_main, "base_llm_http_handler", handler) - litellm.rust(enabled) - rust_bridge._OCR.override(None) - rust_bridge._AOCR.override(None) - for asynchronous in (False, True): - file: Final = BytesIO(b"test document") - arguments: Final = { - "model": "azure_ai/doc-intelligence/prebuilt-layout", - "document": {"type": "file", "file": file}, - "api_key": "test-key", - "pages": [0, 2], - } - if asynchronous: - await litellm.aocr(**arguments) - else: - litellm.ocr(**arguments) - assert handler.calls[-1]["optional_params"]["pages"] == "1,3" - assert handler.calls[-1]["document"]["document_url"].endswith("dGVzdCBkb2N1bWVudA==") - assert len(handler.calls) == 2 - - -@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 - - native: Final = rust_bridge_loader.get_native_bridge() - if native is None: - pytest.skip("requires the compiled Rust extension") - 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() - responses: Final = [] - try: - for enabled in (False, True): - litellm.rust(enabled) - 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) - responses.append(response.model_dump()) - assert len(calls) == 2 - assert calls[0] == calls[1] - for key in ("model", "pages", "object"): - assert responses[0][key] == responses[1][key] - finally: - server.shutdown() - server.server_close() - thread.join(timeout=3) diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py b/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py index e6c8d4ee039..c2f4f7163a0 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py @@ -9266,17 +9266,24 @@ def _agent_prisma(object_permission_id=None, side_effect=None): @contextlib.contextmanager -def _entitlement_fault_globals(prisma_client=None): +def _entitlement_fault_globals(prisma_client=None, user_api_key_cache=None): from litellm.caching.dual_cache import DualCache with ( patch("litellm.proxy.proxy_server.prisma_client", prisma_client or MagicMock()), - patch("litellm.proxy.proxy_server.user_api_key_cache", DualCache()), - patch("litellm.proxy.proxy_server.proxy_logging_obj", MagicMock()), + patch("litellm.proxy.proxy_server.user_api_key_cache", user_api_key_cache or DualCache()), + patch("litellm.proxy.proxy_server.proxy_logging_obj", _proxy_logging_with_awaitable_hooks()), ): yield +def _proxy_logging_with_awaitable_hooks(): + proxy_logging_obj = MagicMock() + proxy_logging_obj.service_logging_obj.async_service_success_hook = AsyncMock() + proxy_logging_obj.service_logging_obj.async_service_failure_hook = AsyncMock() + return proxy_logging_obj + + @pytest.mark.asyncio class TestEntitlementFaultSemantics: """Each entitlement level distinguishes two fault classes for a KEY-authenticated caller. @@ -9412,6 +9419,71 @@ class TestEntitlementFaultSemantics: assert set(allowed) == {"srv1"} +async def _cache_with_end_user(end_user_id, *, mcp_tool_permissions=None, object_permission_id=None): + """A real DualCache already holding the end user row, so ``get_end_user_object`` answers from + cache and no ``litellm.`` internal has to be patched. ``object_permission_id`` without a + permission body models a row that NAMES an entitlement the DB then fails to serve.""" + from litellm.caching.dual_cache import DualCache + from litellm.models.end_user import LiteLLM_EndUserTable + from litellm.proxy.common_utils.user_api_key_cache import end_user_cache_key + + cache = DualCache() + await cache.async_set_cache( + key=end_user_cache_key(end_user_id), + value=LiteLLM_EndUserTable( + user_id=end_user_id, + blocked=False, + object_permission_id=object_permission_id or ("op-eu" if mcp_tool_permissions else None), + object_permission=LiteLLM_ObjectPermissionTable( + object_permission_id="op-eu", mcp_tool_permissions=mcp_tool_permissions + ) + if mcp_tool_permissions + else None, + ), + ) + return cache + + +@pytest.mark.asyncio +class TestEndUserToolCeiling: + """The end user (customer) level narrows the TOOLS axis exactly as it narrows the servers axis, + so `object_permission.mcp_tool_permissions` on `/customer/new` is enforced, not just stored.""" + + async def test_end_user_tool_permissions_intersect_key_tools(self): + auth = _key_auth_reaching("srv1", tools=["tool_a", "tool_b"], end_user_id="eu-1") + cache = await _cache_with_end_user("eu-1", mcp_tool_permissions={"srv1": ["tool_a"]}) + with _entitlement_fault_globals(user_api_key_cache=cache): + tools = await MCPRequestHandler.get_allowed_tools_for_server("srv1", auth) + assert tools == ["tool_a"] + + async def test_end_user_tool_permissions_become_allowlist_when_key_is_unrestricted(self): + auth = _key_auth_reaching("srv1", end_user_id="eu-1") + cache = await _cache_with_end_user("eu-1", mcp_tool_permissions={"srv1": ["tool_a"]}) + with _entitlement_fault_globals(user_api_key_cache=cache): + tools = await MCPRequestHandler.get_allowed_tools_for_server("srv1", auth) + assert tools == ["tool_a"] + + async def test_end_user_tool_permissions_on_another_server_place_no_ceiling(self): + auth = _key_auth_reaching("srv1", tools=["tool_a", "tool_b"], end_user_id="eu-1") + cache = await _cache_with_end_user("eu-1", mcp_tool_permissions={"srv2": ["tool_z"]}) + with _entitlement_fault_globals(user_api_key_cache=cache): + tools = await MCPRequestHandler.get_allowed_tools_for_server("srv1", auth) + assert sorted(tools) == ["tool_a", "tool_b"] + + async def test_end_user_named_but_unloadable_permission_denies_tools(self): + auth = _key_auth_reaching("srv1", tools=["tool_a"], end_user_id="eu-1") + cache = await _cache_with_end_user("eu-1", object_permission_id="op-eu") + with _entitlement_fault_globals(user_api_key_cache=cache): + tools = await MCPRequestHandler.get_allowed_tools_for_server("srv1", auth) + assert tools == [], "an end-user entitlement we know exists but cannot read must deny its tools" + + async def test_no_end_user_row_places_no_tool_ceiling(self): + auth = _key_auth_reaching("srv1", tools=["tool_a"], end_user_id="eu-1") + with _entitlement_fault_globals(user_api_key_cache=await _cache_with_end_user("someone-else")): + tools = await MCPRequestHandler.get_allowed_tools_for_server("srv1", auth) + assert tools == ["tool_a"] + + @pytest.mark.asyncio class TestScopedSessionAdmission: """LIT-4917: a session bearer sealed to one server (RFC 8707 resource at authorize) diff --git a/tests/test_litellm/proxy/client/cli/conftest.py b/tests/test_litellm/proxy/client/cli/conftest.py index c77f516a768..e54dbeef875 100644 --- a/tests/test_litellm/proxy/client/cli/conftest.py +++ b/tests/test_litellm/proxy/client/cli/conftest.py @@ -1,5 +1,6 @@ import os -from collections.abc import Iterator +import shlex +from collections.abc import Callable, Iterator from pathlib import Path from typing import Final @@ -19,12 +20,52 @@ def _statusline_script_under_tmp(monkeypatch: pytest.MonkeyPatch, tmp_path: Path monkeypatch.setattr(claude_settings, "STATUSLINE_SCRIPT_PATH", tmp_path / "litellm-home" / "statusline.py") +@pytest.fixture +def fake_codex_version( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> Callable[[str | None, int], Path]: + directory: Final = tmp_path / "codex-bin" + directory.mkdir() + binary: Final = directory / ("codex.cmd" if os.name == "nt" else "codex") + version_output: Final = directory / "version-output.txt" + monkeypatch.setenv("PATH", str(directory)) + + def install(output: str | None, returncode: int = 0) -> Path: + if output is None: + binary.unlink(missing_ok=True) + return binary + version_output.write_text(output) + if os.name == "nt": + binary.write_text( + '@echo off\nif not "%~1"=="--version" exit /b 2\n' + 'if not "%~2"=="" exit /b 2\ntype "%~dp0version-output.txt"\n' + f'exit /b {returncode}\n' + ) + else: + binary.write_text( + '#!/bin/sh\nif [ "$#" -ne 1 ] || [ "$1" != "--version" ]; then\n exit 2\nfi\n' + f'/bin/cat {shlex.quote(str(version_output))}\nexit {returncode}\n' + ) + binary.chmod(0o700) + return binary + + install("codex-cli 0.129.0\n") + return install + + +@pytest.fixture(autouse=True) +def _isolated_codex_version_for_configure_tests(request: pytest.FixtureRequest) -> None: + if request.node.path.name in ("test_codex_settings.py", "test_configure_commands.py"): + request.getfixturevalue("fake_codex_version") + + @pytest.fixture(autouse=True) def isolated_claude_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[Path]: before: Final = _current_bytes() monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.setenv("USERPROFILE", str(tmp_path)) monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path / ".claude")) + monkeypatch.setenv("CODEX_HOME", str(tmp_path / ".codex")) yield tmp_path after: Final = _current_bytes() if after == before: diff --git a/tests/test_litellm/proxy/client/cli/test_agents.py b/tests/test_litellm/proxy/client/cli/test_agents.py index 7804435a60d..8495940b9c5 100644 --- a/tests/test_litellm/proxy/client/cli/test_agents.py +++ b/tests/test_litellm/proxy/client/cli/test_agents.py @@ -181,7 +181,9 @@ class TestAgentLaunchArgs: assert 'model_providers.litellm.env_key="OPENAI_API_KEY"' in args assert 'model_providers.litellm.wire_api="responses"' in args assert "model_providers.litellm.supports_websockets=false" in args - assert joined.count("-c") == 6 + assert "model_providers.litellm.requires_openai_auth=false" in args + assert "model_providers.litellm.http_headers={}" in args + assert joined.count("-c") == 8 def test_codex_uses_basename(self): assert agent_launch_args("/usr/local/bin/codex", "http://localhost:4000") == ( diff --git a/tests/test_litellm/proxy/client/cli/test_codex_settings.py b/tests/test_litellm/proxy/client/cli/test_codex_settings.py new file mode 100644 index 00000000000..0d1f2b4056a --- /dev/null +++ b/tests/test_litellm/proxy/client/cli/test_codex_settings.py @@ -0,0 +1,341 @@ +import json +import stat +from collections.abc import Callable +from pathlib import Path +from typing import Final + +import pytest +import tomlkit + +from litellm.litellm_core_utils.private_json import commit_staged_json +from litellm.proxy.client.cli.commands import codex_settings as codex_settings_module +from litellm.proxy.client.cli.commands.agents import ( + agent_launch_args, + codex_config_path, +) +from litellm.proxy.client.cli.commands.codex_settings import ( + CodexSettingsError, + _snapshot, + _with, + codex_configure_state_path, + configure_codex_settings, + preflight_codex_settings, + unconfigure_codex_settings, +) + +GATEWAY: Final = "https://gateway.example.com/team" +KEY: Final = "sk-test-new-gateway-key" +MODEL: Final = "gateway-codex-model" + + +@pytest.mark.parametrize("path,existing,first_value,second_value", [ + ("model", 'model = "original" # starting model\n', 'value = "first"\n', 'value = "second"\n'), + ("model_providers.litellm", '', '[value]\nname = "first"\n', '[value]\nname = "second"\n'), + ("model_providers.litellm", '[model_providers.litellm]\nname = "original" # provider\n', + '[value]\nname = "first"\n', '[value]\nname = "second"\n'), +]) +def test_toml_transitions_leave_source_and_independent_results_unchanged( + path: str, existing: str, first_value: str, second_value: str +) -> None: + source: Final = tomlkit.parse('# user settings\n' + existing + '[profiles.work]\nmodel = "keep" # profile\n') + original_bytes: Final = source.as_string().encode() + first: Final = _with(source, path, first_value) + first_bytes: Final = first.as_string().encode() + second: Final = _with(source, path, second_value) + second_bytes: Final = second.as_string().encode() + removed: Final = _with(first, path, None) + first_snapshot: Final = _snapshot(first, path) + second_snapshot: Final = _snapshot(second, path) + assert source.as_string().encode() == original_bytes + assert first.as_string().encode() == first_bytes + assert second.as_string().encode() == second_bytes + assert first_snapshot is not None and tomlkit.parse(first_snapshot) == tomlkit.parse(first_value) + assert second_snapshot is not None and tomlkit.parse(second_snapshot) == tomlkit.parse(second_value) + assert _snapshot(removed, path) is None + for result in (first, second, removed): + assert result["profiles"] == source["profiles"] + assert '# user settings' in result.as_string() + assert '# profile' in result.as_string() + + +def test_persistent_provider_is_complete_and_preserves_unrelated_toml(tmp_path: Path) -> None: + path: Final = tmp_path / "config.toml" + path.write_text( + '# user settings\nmodel = "old-model" # starting model\n' + 'model_provider = "openai"\nprofile = "work"\n' + '[model_providers.litellm]\nname = "old gateway"\n' + 'base_url = "https://old.example.com/v1"\nenv_key = "OLD_KEY"\n' + 'experimental_bearer_token = "sk-old"\nrequires_openai_auth = true\n' + '[model_providers.litellm.auth]\ncommand = "old-token-helper"\n' + '[model_providers.other]\nname = "Keep me" # other provider\n' + '[profiles.work]\nmodel = "work-model"\n' + '[[hooks.Stop]]\nhooks = [{type = "command", command = "echo done"}]\n' + ) + original: Final = tomlkit.parse(path.read_text()) + configure_codex_settings(GATEWAY, KEY, MODEL, path) + configured: Final = tomlkit.parse(path.read_text()) + assert configured["model"] == MODEL + assert configured["model_provider"] == "litellm" + assert "profile" not in configured + assert configured["model_providers"]["litellm"] == { + "name": "LiteLLM proxy", + "base_url": GATEWAY + "/v1", + "wire_api": "responses", + "supports_websockets": False, + "requires_openai_auth": False, + "http_headers": {"Authorization": "Bearer " + KEY}, + } + assert configured["model_providers"]["other"] == original["model_providers"]["other"] + assert configured["profiles"] == original["profiles"] + assert configured["hooks"] == original["hooks"] + assert "# user settings" in path.read_text() + assert "# other provider" in path.read_text() + assert KEY not in codex_configure_state_path(path).read_text() + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + assert stat.S_IMODE(codex_configure_state_path(path).stat().st_mode) == 0o600 + assert stat.S_IMODE(codex_configure_state_path(path).parent.stat().st_mode) == 0o700 + outcome: Final = unconfigure_codex_settings(path) + assert not outcome.kept and not outcome.file_removed + assert tomlkit.parse(path.read_text()) == original + assert "# starting model" in path.read_text() + assert "# other provider" in path.read_text() + assert not codex_configure_state_path(path).exists() + + +@pytest.mark.parametrize("original", [None, "", "# my preferences\n", '[model_providers]\n']) +def test_undo_distinguishes_missing_empty_and_existing_tables(tmp_path: Path, original: str | None) -> None: + path: Final = tmp_path / "config.toml" + if original is not None: + path.write_text(original) + configure_codex_settings(GATEWAY, KEY, MODEL, path) + outcome: Final = unconfigure_codex_settings(path) + assert outcome.file_removed == (original is None) + assert path.exists() == (original is not None) + if original is not None: + assert tomlkit.parse(path.read_text()) == tomlkit.parse(original) + assert original.strip() in path.read_text() + + +def test_repeat_setup_preserves_original_and_undo_keeps_user_edits(tmp_path: Path) -> None: + path: Final = tmp_path / "config.toml" + path.write_text('model = "original"\nmodel_provider = "openai"\n') + configure_codex_settings(GATEWAY, KEY, MODEL, path) + configure_codex_settings(GATEWAY + "/second", "sk-second", "second-model", path) + assert tomlkit.parse(path.read_text())["model"] == "second-model" + path.write_text(path.read_text().replace('model = "second-model"', 'model = "my-custom-model"')) + outcome: Final = unconfigure_codex_settings(path) + assert outcome.kept == ("model",) + assert tomlkit.parse(path.read_text()) == {"model": "my-custom-model", "model_provider": "openai"} + + +def test_repeat_setup_restores_the_user_value_it_displaced(tmp_path: Path) -> None: + path: Final = tmp_path / "config.toml" + path.write_text('model = "original"\n') + configure_codex_settings(GATEWAY, KEY, MODEL, path) + path.write_text(path.read_text().replace('model = "gateway-codex-model"', 'model = "user-edited"')) + configure_codex_settings(GATEWAY, "sk-rotated", "third-model", path) + unconfigure_codex_settings(path) + assert tomlkit.parse(path.read_text()) == {"model": "user-edited"} + + +def test_undo_keeps_provider_credentials_and_endpoint_together(tmp_path: Path) -> None: + path: Final = tmp_path / "config.toml" + path.write_text('[model_providers.litellm]\nbase_url = "https://old.example.com/v1"\n' + 'http_headers = { Authorization = "Bearer old-key" }\n') + configure_codex_settings(GATEWAY, KEY, MODEL, path) + path.write_text(path.read_text().replace(GATEWAY, "https://user.example.com")) + outcome: Final = unconfigure_codex_settings(path) + provider: Final = tomlkit.parse(path.read_text())["model_providers"]["litellm"] + assert outcome.kept == ("model_providers.litellm",) + assert provider["base_url"] == "https://user.example.com/v1" + assert provider["http_headers"] == {"Authorization": "Bearer " + KEY} + assert "old-key" not in path.read_text() + + +def test_user_deleted_config_is_not_recreated_to_restore_profile(tmp_path: Path) -> None: + path: Final = tmp_path / "config.toml" + path.write_text('profile = "old-profile"\n') + configure_codex_settings(GATEWAY, KEY, MODEL, path) + path.unlink() + outcome: Final = unconfigure_codex_settings(path) + assert outcome.file_removed and outcome.restored == () + assert not path.exists() + + +def test_user_comment_in_new_config_survives_undo(tmp_path: Path) -> None: + path: Final = tmp_path / "config.toml" + configure_codex_settings(GATEWAY, KEY, MODEL, path) + path.write_text("# keep my note\n" + path.read_text()) + assert not unconfigure_codex_settings(path).file_removed + assert "# keep my note" in path.read_text() + + +def test_code_home_and_symlink_aliases_share_receipt_and_write_target(tmp_path: Path) -> None: + target: Final = tmp_path / "real-config.toml" + target.write_text('model = "old"\n') + custom_home: Final = tmp_path / "codex-home" + custom_home.mkdir() + alias: Final = codex_config_path({"CODEX_HOME": str(custom_home)}) + alias.symlink_to(target) + configure_codex_settings(GATEWAY, KEY, MODEL, alias) + assert alias.is_symlink() + assert codex_configure_state_path(alias) == codex_configure_state_path(target) + assert tomlkit.parse(target.read_text())["model"] == MODEL + unconfigure_codex_settings(target) + assert alias.is_symlink() + assert tomlkit.parse(alias.read_text()) == {"model": "old"} + + +@pytest.mark.parametrize("invalid", [ + 'token = "sk-secret\n', + 'model_providers = "sk-secret"\n', + '[model_providers]\nlitellm = "sk-secret"\n', +]) +def test_invalid_settings_are_unchanged_and_errors_hide_content(tmp_path: Path, invalid: str) -> None: + path: Final = tmp_path / "config.toml" + path.write_text(invalid) + with pytest.raises(CodexSettingsError) as caught: + configure_codex_settings(GATEWAY, KEY, MODEL, path) + assert "sk-secret" not in str(caught.value) + assert path.read_text() == invalid + assert not codex_configure_state_path(path).exists() + + +def test_invalid_receipt_fails_preflight_before_settings_change(tmp_path: Path) -> None: + path: Final = tmp_path / "config.toml" + path.write_text('model = "keep"\n') + state: Final = codex_configure_state_path(path) + state.parent.mkdir() + state.write_text('{"previous": "sk-secret"}') + with pytest.raises(CodexSettingsError) as caught: + preflight_codex_settings(path) + assert "sk-secret" not in str(caught.value) + assert path.read_text() == 'model = "keep"\n' + + +@pytest.mark.parametrize("configured_before", [False, True]) +@pytest.mark.parametrize("failed_target", ["receipt", "settings"]) +def test_failed_commit_restores_receipt_and_cleans_private_staging( + tmp_path: Path, configured_before: bool, failed_target: str +) -> None: + path: Final = tmp_path / "config.toml" + path.write_text('model = "old"\n') + state: Final = codex_configure_state_path(path) + if configured_before: + configure_codex_settings(GATEWAY, KEY, MODEL, path) + before: Final = path.read_bytes() + receipt_before: Final = state.read_bytes() if state.exists() else None + + def failing_commit(staged: str, destination: str) -> None: + if destination == str(state if failed_target == "receipt" else path.resolve()): + raise OSError("sk-secret OS error") + commit_staged_json(staged, destination) + + with pytest.raises(CodexSettingsError) as caught: + configure_codex_settings(GATEWAY, "sk-replacement", "new-model", path, commit=failing_commit) + assert "sk-secret" not in str(caught.value) + assert path.read_bytes() == before + assert (state.read_bytes() if state.exists() else None) == receipt_before + assert not tuple(tmp_path.rglob(".tmp-*")) + if configured_before: + unconfigure_codex_settings(path) + assert tomlkit.parse(path.read_text()) == {"model": "old"} + + +def test_failed_undo_keeps_the_settings_and_receipt_for_retry(tmp_path: Path) -> None: + path: Final = tmp_path / "config.toml" + path.write_text('model = "old"\n') + configure_codex_settings(GATEWAY, KEY, MODEL, path) + before: Final = path.read_bytes() + + def fail(staged: str, destination: str) -> None: + raise OSError("cannot replace") + + with pytest.raises(CodexSettingsError): + unconfigure_codex_settings(path, commit=fail) + assert path.read_bytes() == before + assert codex_configure_state_path(path).exists() + assert not tuple(tmp_path.rglob(".tmp-*")) + unconfigure_codex_settings(path) + assert tomlkit.parse(path.read_text()) == {"model": "old"} + + +def test_wrapper_and_persistent_provider_agree_except_credential_source(tmp_path: Path) -> None: + path: Final = tmp_path / "config.toml" + configure_codex_settings(GATEWAY, KEY, MODEL, path) + provider: Final = tomlkit.parse(path.read_text())["model_providers"]["litellm"] + args: Final = agent_launch_args("codex", GATEWAY) + overrides: Final = dict(argument.split("=", 1) for argument in args[1::2]) + for field in ("name", "base_url", "wire_api", "supports_websockets", "requires_openai_auth"): + assert json.loads(overrides[f"model_providers.litellm.{field}"]) == provider[field] + assert overrides["model_providers.litellm.http_headers"] == "{}" + assert overrides["model_providers.litellm.env_key"] == '"OPENAI_API_KEY"' + + +@pytest.mark.parametrize("version", [ + "codex-cli 0.129.0", "codex-cli 0.129.1", "codex-cli 0.130.0", "codex-cli 1.0.0", + " \ncodex-cli 0.129.0\n", +]) +def test_version_guard_accepts_the_fixed_release_and_newer_stable_versions(version: str) -> None: + assert codex_settings_module.require_safe_codex(version=lambda: version) is None + + +@pytest.mark.parametrize("version", [ + None, "", "codex-cli 0.99.0", "codex-cli 0.128.99", "codex-cli 0.129.0-alpha.1", + "codex-cli 1.0.0-beta.1", "0.129.0", "codex-cli 0.129.0 extra", "unparseable-sk-version-secret", +]) +def test_version_guard_refuses_missing_unsafe_or_unrecognized_versions(version: str | None) -> None: + with pytest.raises(CodexSettingsError) as caught: + codex_settings_module.require_safe_codex(version=lambda: version) + assert "0.129.0" in str(caught.value) + assert "sk-version-secret" not in str(caught.value) + + +@pytest.mark.parametrize("output,returncode", [ + (None, 0), + ("codex-cli 0.129.0\n", 7), +]) +def test_version_probe_handles_missing_or_failed_executable( + fake_codex_version: Callable[[str | None, int], Path], output: str | None, returncode: int +) -> None: + fake_codex_version(output, returncode) + assert codex_settings_module._codex_version() is None + + +@pytest.mark.parametrize("output,returncode", [ + (None, 0), + ("codex-cli 0.128.0\n", 0), + ("codex-cli 0.129.0-alpha.1\n", 0), + ("unparseable-sk-version-secret\n", 0), + ("codex-cli 0.129.0\n", 7), +]) +def test_writer_checks_the_installed_codex_before_replacing_a_key_or_receipt( + tmp_path: Path, fake_codex_version: Callable[[str | None, int], Path], + output: str | None, returncode: int, +) -> None: + path: Final = tmp_path / "config.toml" + path.write_text('model = "original"\n') + configure_codex_settings(GATEWAY, "sk-existing-gateway", MODEL, path) + state: Final = codex_configure_state_path(path) + before: Final = (path.read_bytes(), state.read_bytes()) + fake_codex_version(output, returncode) + with pytest.raises(CodexSettingsError) as caught: + configure_codex_settings(GATEWAY, KEY, "replacement-model", path) + assert "0.129.0" in str(caught.value) + assert KEY not in str(caught.value) and "sk-version-secret" not in str(caught.value) + assert (path.read_bytes(), state.read_bytes()) == before + assert not tuple(tmp_path.rglob(".tmp-*")) + + +def test_undo_does_not_require_codex_to_remain_installed( + tmp_path: Path, fake_codex_version: Callable[[str | None, int], Path] +) -> None: + path: Final = tmp_path / "config.toml" + original: Final = 'model = "original"\n' + path.write_text(original) + configure_codex_settings(GATEWAY, KEY, MODEL, path) + fake_codex_version(None, 0) + outcome: Final = unconfigure_codex_settings(path) + assert outcome.restored and not outcome.kept + assert tomlkit.parse(path.read_text()) == tomlkit.parse(original) + assert not codex_configure_state_path(path).exists() diff --git a/tests/test_litellm/proxy/client/cli/test_configure_commands.py b/tests/test_litellm/proxy/client/cli/test_configure_commands.py index 8ed188af737..8f68bb1320b 100644 --- a/tests/test_litellm/proxy/client/cli/test_configure_commands.py +++ b/tests/test_litellm/proxy/client/cli/test_configure_commands.py @@ -1,11 +1,16 @@ +import io import json import os import stat +import time +from pathlib import Path +from types import SimpleNamespace import click import pytest import requests import responses +import tomlkit from click.testing import CliRunner from litellm.proxy.client.cli import cli @@ -36,6 +41,8 @@ def paths(monkeypatch, tmp_path): monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(settings_path.parent)) monkeypatch.setattr(claude_settings_module, "CLAUDE_SETTINGS_PATH", settings_path) monkeypatch.setattr(claude_settings_module, "CONFIGURE_STATE_PATH", state_path) + monkeypatch.delenv("LITELLM_PROXY_API_KEY", raising=False) + monkeypatch.delenv("LITELLM_PROXY_URL", raising=False) return settings_path, state_path @@ -56,6 +63,29 @@ def runner(): return CliRunner() +@pytest.fixture +def codex_path(): + return Path(os.environ["CODEX_HOME"]) / "config.toml" + + +class _TerminalInput(io.BytesIO): + def isatty(self): + return True + + +def _mock_agent_models(): + def listing(request): + assert request.headers["Authorization"] == f"Bearer {VALID_KEY}" + rows = ( + [{"id": "claude-router-6175746f", "source_model": "auto"}] + if request.headers.get("x-gateway-client") == "claude-code" + else [{"id": "auto"}] + ) + return 200, {"Content-Type": "application/json"}, json.dumps({"data": rows}) + + responses.add_callback(responses.GET, f"{PROXY}/v1/models", callback=listing) + + @pytest.fixture def lite_up_backup(monkeypatch, tmp_path): """A `lite up` session holding its backup, the local precondition every settings write refuses on.""" @@ -253,6 +283,259 @@ class TestInteractiveConfigure: assert "lite configure claude --api-key" in result.output +class TestConfigureAgents: + @responses.activate + @pytest.mark.parametrize("targets", [("claude",), ("codex",), ("claude", "codex")]) + def test_group_options_drive_the_agent_picker_and_write_only_selected_agents( + self, runner, paths, codex_path, monkeypatch, targets + ): + _mock_agent_models() + asked = [] + + def checkbox(**kwargs): + assert tuple(choice.value for choice in kwargs["choices"]) == ("claude", "codex") + return SimpleNamespace(execute=lambda: targets) + + def fuzzy(**kwargs): + assert "auto" in kwargs["choices"] + assert "claude-router-6175746f" not in kwargs["choices"] + assert not paths[0].exists() and not codex_path.exists() + asked.append(kwargs["message"]) + return SimpleNamespace(execute=lambda: "auto") + + monkeypatch.setattr(configure_module.inquirer, "checkbox", checkbox) + monkeypatch.setattr(configure_module.inquirer, "fuzzy", fuzzy) + result = runner.invoke( + cli, + ["configure", "--api-key", VALID_KEY, "--gateway-url", f"{PROXY}/v1/"], + input=_TerminalInput(), + ) + assert result.exit_code == 0, result.output + assert VALID_KEY not in result.output + assert len(asked) == len(targets) + assert paths[0].exists() == ("claude" in targets) + assert codex_path.exists() == ("codex" in targets) + if "claude" in targets: + claude = json.loads(paths[0].read_text()) + assert claude["model"] == "claude-router-6175746f" + assert claude["env"]["ANTHROPIC_BASE_URL"] == PROXY + assert claude["env"]["ANTHROPIC_AUTH_TOKEN"] == VALID_KEY + if "codex" in targets: + codex = tomlkit.parse(codex_path.read_text()) + assert codex["model"] == "auto" + assert codex["model_provider"] == "litellm" + provider = codex["model_providers"]["litellm"] + assert provider["base_url"] == f"{PROXY}/v1" + assert provider["http_headers"]["Authorization"] == f"Bearer {VALID_KEY}" + assert "env_key" not in provider + assert [call.request.headers.get("x-gateway-client") for call in responses.calls] == [ + "claude-code" if target == "claude" else None for target in targets + ] + + @responses.activate + @pytest.mark.parametrize("target", ["claude", "codex"]) + @pytest.mark.parametrize("leaf_override", [False, True], ids=["inherit-group", "leaf-wins"]) + def test_group_connection_options_are_inherited_and_leaf_options_take_precedence( + self, runner, paths, codex_path, target, leaf_override + ): + _mock_agent_models() + group_url = "http://group.test" if leaf_override else PROXY + group_key = "sk-group" if leaf_override else VALID_KEY + args = [ + "--base-url", "http://global.test", "--api-key", "sk-global", "configure", + "--gateway-url", group_url, "--api-key", group_key, target, "--model", "auto", + ] + if leaf_override: + args.extend(["--base-url", f"{PROXY}/v1/", "--api-key", VALID_KEY]) + result = runner.invoke(cli, args) + assert result.exit_code == 0, result.output + assert all(key not in result.output for key in (VALID_KEY, group_key, "sk-global")) + if target == "claude": + written = json.loads(paths[0].read_text()) + assert written["env"]["ANTHROPIC_AUTH_TOKEN"] == VALID_KEY + assert written["env"]["ANTHROPIC_BASE_URL"] == PROXY + assert not codex_path.exists() + else: + provider = tomlkit.parse(codex_path.read_text())["model_providers"]["litellm"] + assert provider["http_headers"]["Authorization"] == f"Bearer {VALID_KEY}" + assert provider["base_url"] == f"{PROXY}/v1" + assert not paths[0].exists() + assert len(responses.calls) == 1 + + @responses.activate + @pytest.mark.parametrize("failure", ["invalid-model", "cancel"]) + def test_both_model_choices_complete_before_either_configuration_changes( + self, paths, codex_path, failure + ): + _mock_agent_models() + settings_path, state_path = paths + settings_path.parent.mkdir(parents=True) + settings_path.write_text('{"theme": "dark"}') + codex_path.parent.mkdir(parents=True) + codex_path.write_text('model = "original"\n') + before = (settings_path.read_bytes(), codex_path.read_bytes()) + + def pick_codex_model(listed): + assert listed == ("auto",) + assert (settings_path.read_bytes(), codex_path.read_bytes()) == before + if failure == "cancel": + raise KeyboardInterrupt() + return "not-listed" + + ctx = click.Context(configure_group, obj={"base_url": PROXY, "api_key": VALID_KEY}) + expected = KeyboardInterrupt if failure == "cancel" else click.ClickException + with pytest.raises(expected): + interactive_configure( + ctx, + pick_targets=lambda: ("claude", "codex"), + pick_model=lambda listed: "auto", + pick_codex_model=pick_codex_model, + ) + assert (settings_path.read_bytes(), codex_path.read_bytes()) == before + assert not state_path.exists() + assert not (codex_path.parent / ".litellm").exists() + + @responses.activate + def test_both_configs_are_preflighted_before_fetching_models_or_writing( + self, paths, codex_path + ): + _mock_agent_models() + codex_path.parent.mkdir(parents=True) + codex_path.write_text("[invalid") + ctx = click.Context(configure_group, obj={"base_url": PROXY, "api_key": VALID_KEY}) + with pytest.raises(click.ClickException, match="Could not read Codex settings"): + interactive_configure( + ctx, + pick_targets=lambda: ("claude", "codex"), + pick_model=lambda listed: "auto", + pick_codex_model=lambda listed: "auto", + ) + assert not paths[0].exists() and not paths[1].exists() + assert codex_path.read_text() == "[invalid" + assert len(responses.calls) == 0 + + @responses.activate + @pytest.mark.parametrize("targets", [("claude", "codex"), ("codex", "claude")]) + @pytest.mark.parametrize("version", [None, "codex-cli 0.128.0\n"]) + def test_unsafe_codex_blocks_both_targets_before_requests_or_writes( + self, paths, codex_path, fake_codex_version, targets, version + ): + _mock_agent_models() + fake_codex_version(version, 0) + ctx = click.Context(configure_group, obj={"base_url": PROXY, "api_key": VALID_KEY}) + with pytest.raises(click.ClickException, match=r"0\.129\.0") as caught: + interactive_configure( + ctx, + pick_targets=lambda: targets, + pick_model=lambda listed: "auto", + pick_codex_model=lambda listed: "auto", + ) + assert VALID_KEY not in str(caught.value) + assert len(responses.calls) == 0 + assert not paths[0].exists() and not paths[1].exists() + assert not codex_path.exists() and not (codex_path.parent / ".litellm").exists() + + @responses.activate + def test_claude_only_configuration_does_not_require_codex( + self, runner, paths, codex_path, fake_codex_version + ): + _mock_agent_models() + fake_codex_version(None, 0) + result = runner.invoke( + cli, ["configure", "--api-key", VALID_KEY, "--gateway-url", PROXY, "claude", "--model", "auto"] + ) + assert result.exit_code == 0, result.output + assert json.loads(paths[0].read_text())["model"] == "claude-router-6175746f" + assert not codex_path.exists() + + @responses.activate + def test_codex_only_ignores_claudes_temporary_owner( + self, runner, paths, codex_path, lite_up_backup + ): + _mock_agent_models() + result = runner.invoke( + cli, ["configure", "--api-key", VALID_KEY, "--gateway-url", PROXY, "codex", "--model", "auto"] + ) + assert result.exit_code == 0, result.output + assert tomlkit.parse(codex_path.read_text())["model"] == "auto" + assert not paths[0].exists() and not paths[1].exists() + assert lite_up_backup.exists() + + def test_noninteractive_codex_requires_a_model(self, runner, paths, codex_path): + result = runner.invoke(cli, ["configure", "--api-key", VALID_KEY, "--gateway-url", PROXY, "codex"]) + assert result.exit_code != 0 and "Missing option '--model'" in result.output + assert not paths[0].exists() and not codex_path.exists() + + @responses.activate + @pytest.mark.parametrize("target", ["claude", "codex"]) + @pytest.mark.parametrize( + "option, value, expected", + [ + ("--api-key", "sk-secret\ninvalid", "must not be blank"), + ("--gateway-url", "https://user:sk-secret@proxy.test", "must not contain credentials"), + ("--gateway-url", "https://proxy.test?key=sk-secret", "must not include a query"), + ("--gateway-url", "file:///sk-secret", "must be a full http:// or https:// URL"), + ], + ) + def test_invalid_connection_input_never_writes_requests_or_echoes_secrets( + self, runner, paths, codex_path, target, option, value, expected + ): + result = runner.invoke( + cli, + [ + "configure", "--api-key", VALID_KEY, "--gateway-url", PROXY, + target, "--model", "auto", option, value, + ], + ) + assert result.exit_code != 0 and expected in result.output + assert "sk-secret" not in result.output and VALID_KEY not in result.output + assert not paths[0].exists() and not codex_path.exists() + assert len(responses.calls) == 0 + + @responses.activate + @pytest.mark.parametrize("failure", ["rejected", "connection", "response-body"]) + def test_gateway_failures_never_echo_the_key(self, runner, paths, codex_path, failure): + if failure == "rejected": + responses.get(f"{PROXY}/v1/models", status=401) + elif failure == "connection": + responses.get(f"{PROXY}/v1/models", body=requests.ConnectionError(VALID_KEY)) + else: + responses.get(f"{PROXY}/v1/models", json={"data": VALID_KEY}) + result = runner.invoke( + cli, ["configure", "--api-key", VALID_KEY, "--gateway-url", PROXY, "codex", "--model", "auto"] + ) + assert result.exit_code != 0 and "Error:" in result.output + assert VALID_KEY not in result.output + assert not paths[0].exists() and not codex_path.exists() + + @responses.activate + def test_configure_and_unconfigure_do_not_read_a_stored_login( + self, runner, paths, codex_path, tmp_path, secret_vault_factory, fake_codex_version + ): + _mock_agent_models() + token_path = tmp_path / ".litellm" / "token.json" + token_path.parent.mkdir() + token_path.write_text(json.dumps({"base_url": PROXY, "timestamp": time.time()})) + vault = secret_vault_factory(json.dumps({"base_url": PROXY, "key": "sk-login", "jwt_token": ""})) + missing = runner.invoke( + cli, ["configure", "--gateway-url", PROXY, "codex", "--model", "auto"], obj={"secret_vault": vault} + ) + assert missing.exit_code != 0 and "needs a long-lived virtual key" in missing.output + assert len(responses.calls) == 0 and not codex_path.exists() + configured = runner.invoke( + cli, + ["configure", "--api-key", VALID_KEY, "--gateway-url", PROXY, "codex", "--model", "auto"], + obj={"secret_vault": vault}, + ) + assert configured.exit_code == 0, configured.output + fake_codex_version(None, 0) + undone = runner.invoke(cli, ["unconfigure", "codex"], obj={"secret_vault": vault}) + assert undone.exit_code == 0, undone.output + assert vault.reads == 0 and vault.writes == [] and vault.erases == 0 + assert not codex_path.exists() and not paths[0].exists() + assert "Removed" in undone.output and "sk-login" not in missing.output + configured.output + undone.output + + class TestUnconfigureClaude: @responses.activate def test_restores_the_original_file_and_removes_the_receipt(self, runner, paths): diff --git a/tests/test_litellm/proxy/common_utils/test_load_config_utils.py b/tests/test_litellm/proxy/common_utils/test_load_config_utils.py index 524c260e94a..1042dbe9653 100644 --- a/tests/test_litellm/proxy/common_utils/test_load_config_utils.py +++ b/tests/test_litellm/proxy/common_utils/test_load_config_utils.py @@ -1,9 +1,18 @@ +import asyncio +import logging +import re +import threading from unittest.mock import MagicMock, mock_open, patch import pytest import yaml -from litellm.proxy.common_utils.load_config_utils import get_file_contents_from_s3 +from litellm.proxy.common_utils.load_config_utils import ( + gcs_config_bucket, + get_config_from_bucket, + get_file_contents_from_s3, + resolve_bucket_includes, +) class TestGetFileContentsFromS3: @@ -83,3 +92,385 @@ class TestGetFileContentsFromS3: # Verify yaml.safe_load was called with the decoded content mock_yaml_load.assert_called_once_with(yaml_content) + + +class TestBucketConfigIncludes: + + @staticmethod + def _bucket(objects): + async def fetch(object_key): + return objects.get(object_key) + + return fetch + + @pytest.mark.asyncio + async def test_include_resolves_against_the_config_objects_prefix(self): + merged = await resolve_bucket_includes( + config={"include": ["model_config.yaml"], "general_settings": {"master_key": "sk-1234"}}, + object_key="configs/prod/config.yaml", + fetch=self._bucket( + {"configs/prod/model_config.yaml": {"model_list": [{"model_name": "gpt-4o-mini"}]}} + ), + ) + + assert merged == { + "general_settings": {"master_key": "sk-1234"}, + "model_list": [{"model_name": "gpt-4o-mini"}], + } + + @pytest.mark.asyncio + async def test_include_with_a_leading_slash_reads_from_the_bucket_root(self): + merged = await resolve_bucket_includes( + config={"include": ["/shared/models.yaml"]}, + object_key="configs/prod/config.yaml", + fetch=self._bucket({"shared/models.yaml": {"model_list": [{"model_name": "shared"}]}}), + ) + + assert merged == {"model_list": [{"model_name": "shared"}]} + + @pytest.mark.asyncio + async def test_include_walks_out_of_the_prefix_with_dot_dot(self): + merged = await resolve_bucket_includes( + config={"include": ["../shared/models.yaml"]}, + object_key="configs/prod/config.yaml", + fetch=self._bucket({"configs/shared/models.yaml": {"model_list": [{"model_name": "shared"}]}}), + ) + + assert merged == {"model_list": [{"model_name": "shared"}]} + + @pytest.mark.asyncio + async def test_included_configs_may_declare_further_includes(self): + merged = await resolve_bucket_includes( + config={"include": ["models.yaml"]}, + object_key="configs/config.yaml", + fetch=self._bucket( + { + "configs/models.yaml": { + "include": ["extra/more_models.yaml"], + "model_list": [{"model_name": "first"}], + }, + "configs/extra/more_models.yaml": {"model_list": [{"model_name": "second"}]}, + } + ), + ) + + assert merged == {"model_list": [{"model_name": "first"}, {"model_name": "second"}]} + + @pytest.mark.asyncio + async def test_a_nested_include_resolves_against_the_object_that_declares_it(self): + merged = await resolve_bucket_includes( + config={"include": ["shared/models.yaml"]}, + object_key="configs/config.yaml", + fetch=self._bucket( + { + "configs/shared/models.yaml": { + "include": ["more_models.yaml"], + "model_list": [{"model_name": "first"}], + }, + "configs/shared/more_models.yaml": {"model_list": [{"model_name": "second"}]}, + "configs/more_models.yaml": {"model_list": [{"model_name": "wrong-prefix"}]}, + } + ), + ) + + assert merged == {"model_list": [{"model_name": "first"}, {"model_name": "second"}]} + + @pytest.mark.asyncio + async def test_an_object_pulled_in_twice_is_merged_once(self): + merged = await resolve_bucket_includes( + config={"include": ["a.yaml", "b.yaml"]}, + object_key="configs/config.yaml", + fetch=self._bucket( + { + "configs/a.yaml": {"include": ["shared.yaml"]}, + "configs/b.yaml": {"include": ["./shared.yaml"]}, + "configs/shared.yaml": {"model_list": [{"model_name": "shared"}]}, + } + ), + ) + + assert merged == {"model_list": [{"model_name": "shared"}]} + + @pytest.mark.asyncio + async def test_a_cycle_between_included_objects_terminates(self): + merged = await asyncio.wait_for( + resolve_bucket_includes( + config={"include": ["a.yaml"]}, + object_key="configs/config.yaml", + fetch=self._bucket( + { + "configs/a.yaml": {"include": ["b.yaml"], "model_list": [{"model_name": "from-a"}]}, + "configs/b.yaml": {"include": ["a.yaml"], "model_list": [{"model_name": "from-b"}]}, + } + ), + ), + timeout=10, + ) + + assert merged == {"model_list": [{"model_name": "from-a"}, {"model_name": "from-b"}]} + + @pytest.mark.asyncio + async def test_list_values_are_extended_and_other_values_are_overridden(self): + merged = await resolve_bucket_includes( + config={ + "include": ["models.yaml"], + "model_list": [{"model_name": "from-root"}], + "litellm_settings": {"drop_params": True}, + }, + object_key="config.yaml", + fetch=self._bucket( + { + "models.yaml": { + "model_list": [{"model_name": "from-include"}], + "litellm_settings": {"num_retries": 3}, + } + } + ), + ) + + assert merged == { + "model_list": [{"model_name": "from-root"}, {"model_name": "from-include"}], + "litellm_settings": {"num_retries": 3}, + } + + @pytest.mark.asyncio + async def test_a_missing_included_object_fails_loudly_with_its_key(self): + with pytest.raises(FileNotFoundError, match=re.escape("configs/prod/model_config.yaml")): + await resolve_bucket_includes( + config={"include": ["model_config.yaml"]}, + object_key="configs/prod/config.yaml", + fetch=self._bucket({}), + ) + + @pytest.mark.asyncio + async def test_a_non_list_include_fails_loudly(self): + with pytest.raises(ValueError, match="'include' must be a list of file paths"): + await resolve_bucket_includes( + config={"include": "model_config.yaml"}, + object_key="config.yaml", + fetch=self._bucket({}), + ) + + @pytest.mark.asyncio + async def test_get_config_from_bucket_merges_includes_over_s3(self, monkeypatch): + objects = { + "lit6982/config.yaml": { + "include": ["model_config.yaml"], + "general_settings": {"master_key": "sk-1234"}, + }, + "lit6982/model_config.yaml": {"model_list": [{"model_name": "included-model"}]}, + } + monkeypatch.setattr( + "litellm.proxy.common_utils.load_config_utils.s3_object_reader", + lambda bucket_name: objects.get, + ) + + config = await get_config_from_bucket( + bucket_type="s3", bucket_name="litellm-configs", object_key="lit6982/config.yaml" + ) + + assert config == { + "general_settings": {"master_key": "sk-1234"}, + "model_list": [{"model_name": "included-model"}], + } + + @pytest.mark.asyncio + async def test_the_blocking_s3_work_runs_off_the_event_loop_thread(self, monkeypatch): + loop_thread = threading.current_thread() + threads = [] + + def build_reader(bucket_name): + threads.append(threading.current_thread()) + + def read(object_key): + threads.append(threading.current_thread()) + return {"model_list": [{"model_name": "a-model"}]} + + return read + + monkeypatch.setattr("litellm.proxy.common_utils.load_config_utils.s3_object_reader", build_reader) + + await get_config_from_bucket(bucket_type="s3", bucket_name="litellm-configs", object_key="config.yaml") + + assert len(threads) == 2 and loop_thread not in threads + + @pytest.mark.asyncio + async def test_one_s3_client_serves_the_whole_include_tree(self, monkeypatch): + objects = { + "lit6982/config.yaml": {"include": ["model_config.yaml"]}, + "lit6982/model_config.yaml": {"model_list": [{"model_name": "included-model"}]}, + } + readers = [] + + def build_reader(bucket_name): + requested = [] + readers.append(requested) + + def read(object_key): + requested.append(object_key) + return objects.get(object_key) + + return read + + monkeypatch.setattr("litellm.proxy.common_utils.load_config_utils.s3_object_reader", build_reader) + + await get_config_from_bucket( + bucket_type="s3", bucket_name="litellm-configs", object_key="lit6982/config.yaml" + ) + + assert readers == [["lit6982/config.yaml", "lit6982/model_config.yaml"]] + + @pytest.mark.asyncio + async def test_an_empty_included_object_merges_as_an_empty_config(self, monkeypatch): + objects = { + "lit6982/config.yaml": "include:\n - empty.yaml\nmodel_list:\n - model_name: only-model\n", + "lit6982/empty.yaml": "", + } + + class FakeGCSBucket: + async def download_gcs_object(self, object_key): + return objects[object_key].encode("utf-8") + + monkeypatch.setattr( + "litellm.proxy.common_utils.load_config_utils.gcs_config_bucket", + lambda bucket_name: FakeGCSBucket(), + ) + + config = await get_config_from_bucket( + bucket_type="gcs", bucket_name="litellm-configs", object_key="lit6982/config.yaml" + ) + + assert config == {"model_list": [{"model_name": "only-model"}]} + + @pytest.mark.asyncio + async def test_get_config_from_bucket_merges_includes_over_gcs(self, monkeypatch): + objects = { + "lit6982/config.yaml": { + "include": ["model_config.yaml"], + "general_settings": {"master_key": "sk-1234"}, + }, + "lit6982/model_config.yaml": {"model_list": [{"model_name": "included-model"}]}, + } + + buckets = [] + + class FakeGCSBucket: + def __init__(self): + self.requested = [] + buckets.append(self) + + async def download_gcs_object(self, object_key): + self.requested.append(object_key) + return yaml.dump(objects[object_key]).encode("utf-8") + + monkeypatch.setattr( + "litellm.proxy.common_utils.load_config_utils.gcs_config_bucket", + lambda bucket_name: FakeGCSBucket(), + ) + + config = await get_config_from_bucket( + bucket_type="gcs", bucket_name="litellm-configs", object_key="lit6982/config.yaml" + ) + + assert config == { + "general_settings": {"master_key": "sk-1234"}, + "model_list": [{"model_name": "included-model"}], + } + assert [bucket.requested for bucket in buckets] == [ + ["lit6982/config.yaml", "lit6982/model_config.yaml"] + ] + + @pytest.mark.asyncio + async def test_get_config_from_bucket_returns_none_when_the_root_object_is_missing(self, monkeypatch): + monkeypatch.setattr( + "litellm.proxy.common_utils.load_config_utils.s3_object_reader", + lambda bucket_name: (lambda object_key: None), + ) + + assert ( + await get_config_from_bucket( + bucket_type="s3", bucket_name="litellm-configs", object_key="missing.yaml" + ) + is None + ) + + @pytest.mark.asyncio + async def test_an_object_pulled_in_twice_is_read_once(self): + objects = { + "configs/a.yaml": {"include": ["shared.yaml"]}, + "configs/b.yaml": {"include": ["./shared.yaml"]}, + "configs/shared.yaml": {"model_list": [{"model_name": "shared"}]}, + } + requested = [] + + async def fetch(object_key): + requested.append(object_key) + return objects.get(object_key) + + await resolve_bucket_includes( + config={"include": ["a.yaml", "b.yaml"]}, + object_key="configs/config.yaml", + fetch=fetch, + ) + + assert requested == ["configs/a.yaml", "configs/b.yaml", "configs/shared.yaml"] + + @pytest.mark.asyncio + async def test_an_empty_root_object_does_not_boot_an_empty_proxy(self, monkeypatch): + class FakeGCSBucket: + async def download_gcs_object(self, object_key): + return b"" + + monkeypatch.setattr( + "litellm.proxy.common_utils.load_config_utils.gcs_config_bucket", + lambda bucket_name: FakeGCSBucket(), + ) + + config = await get_config_from_bucket( + bucket_type="gcs", bucket_name="litellm-configs", object_key="lit6982/config.yaml" + ) + + assert config is None + + @pytest.mark.asyncio + async def test_an_object_that_is_not_valid_yaml_is_reported_as_a_yaml_error(self, monkeypatch, caplog): + class FakeGCSBucket: + async def download_gcs_object(self, object_key): + return b"model_list: [\n" + + monkeypatch.setattr( + "litellm.proxy.common_utils.load_config_utils.gcs_config_bucket", + lambda bucket_name: FakeGCSBucket(), + ) + + with caplog.at_level(logging.ERROR, logger="LiteLLM Proxy"): + config = await get_config_from_bucket( + bucket_type="gcs", bucket_name="litellm-configs", object_key="lit6982/config.yaml" + ) + + assert config is None + assert [ + record + for record in caplog.records + if "not valid YAML" in record.getMessage() and "lit6982/config.yaml" in record.getMessage() + ] + + +class TestGCSConfigBucketClient: + @pytest.mark.asyncio + async def test_reading_a_config_from_gcs_does_not_need_an_enterprise_license(self, monkeypatch): + monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", False) + + bucket = gcs_config_bucket("litellm-configs") + + assert bucket is not None + assert bucket.BUCKET_NAME == "litellm-configs" + + @pytest.mark.asyncio + async def test_reading_a_config_from_gcs_starts_no_background_task(self, monkeypatch): + monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", True) + running_before = asyncio.all_tasks() + + gcs_config_bucket("litellm-configs") + + assert asyncio.all_tasks() - running_before == set() diff --git a/tests/test_litellm/proxy/common_utils/test_scheduled_job_stagger.py b/tests/test_litellm/proxy/common_utils/test_scheduled_job_stagger.py index 54e0aa74a25..3eabfc5c840 100644 --- a/tests/test_litellm/proxy/common_utils/test_scheduled_job_stagger.py +++ b/tests/test_litellm/proxy/common_utils/test_scheduled_job_stagger.py @@ -64,6 +64,10 @@ def _stagger(scheduler: AsyncIOScheduler, identity: str = "pod-a:1", **overrides return apply_scheduled_job_stagger(scheduler=scheduler, settings=_settings(**overrides), identity=identity) +def _trigger_of(scheduler: AsyncIOScheduler, job_id: str): + return next(job.trigger for job in scheduler.get_jobs() if job.id == job_id) + + def _fire_times(trigger, start: datetime, steps: int) -> tuple[datetime, ...]: """The fire times APScheduler would produce, each computed from the one before it""" return tuple( @@ -133,22 +137,24 @@ def test_default_cron_is_staggered_and_keeps_its_offset_on_every_later_fire(): applied = _stagger(scheduler) assert applied[PTU_ROLLUP_JOB_ID] > 0 - trigger = next(job.trigger for job in scheduler.get_jobs() if job.id == PTU_ROLLUP_JOB_ID) - fires = _fire_times(trigger, datetime(2026, 1, 1, 12, 0, tzinfo=timezone.utc), 3) + start = datetime(2026, 1, 1, 12, 0, tzinfo=timezone.utc) + fires = _fire_times(_trigger_of(scheduler, PTU_ROLLUP_JOB_ID), start, 3) expected = timedelta(minutes=15) + timedelta(seconds=applied[PTU_ROLLUP_JOB_ID]) assert [fire - fire.replace(hour=0, minute=0, second=0, microsecond=0) for fire in fires] == [expected] * 3 -async def test_explicit_offset_overrides_the_derived_one_and_zero_pins_a_job(): +def test_explicit_offset_overrides_the_derived_one_and_zero_pins_a_job(): scheduler = _with_jobs(_scheduler()) applied = _stagger(scheduler, offsets={"periodic_reload_job": 0, PTU_ROLLUP_JOB_ID: 7}) - unstaggered = _next_run_times(_with_jobs(_scheduler())) - staggered = _next_run_times(scheduler) assert applied["periodic_reload_job"] == 0 assert applied[PTU_ROLLUP_JOB_ID] == 7 - assert staggered[PTU_ROLLUP_JOB_ID] - unstaggered[PTU_ROLLUP_JOB_ID] == timedelta(seconds=7) + + start = datetime(2026, 1, 1, 12, 0, tzinfo=timezone.utc) + staggered = _trigger_of(scheduler, PTU_ROLLUP_JOB_ID) + unstaggered = _trigger_of(_with_jobs(_scheduler()), PTU_ROLLUP_JOB_ID) + assert _fire_times(staggered, start, 1)[0] - _fire_times(unstaggered, start, 1)[0] == timedelta(seconds=7) async def test_disabling_the_stagger_leaves_every_schedule_untouched(): diff --git a/tests/test_litellm/proxy/db/conftest.py b/tests/test_litellm/proxy/db/conftest.py index d3226b0ec50..bcb7794a20d 100644 --- a/tests/test_litellm/proxy/db/conftest.py +++ b/tests/test_litellm/proxy/db/conftest.py @@ -35,6 +35,14 @@ DB_ENV_KEYS = ( _db_env_snapshot_key = pytest.StashKey[dict[str, Optional[str]]]() +def _is_zombie(pid: int) -> bool: + try: + stat: Final = Path(f"/proc/{pid}/stat").read_text() + except OSError: + return False + return stat.rpartition(")")[2].split()[0] == "Z" + + def _db_env_snapshot() -> dict[str, Optional[str]]: return {key: os.environ.get(key) for key in DB_ENV_KEYS} @@ -136,6 +144,8 @@ class FakePrismaCli: os.kill(pid, 0) except ProcessLookupError: return True + if _is_zombie(pid): + return True time.sleep(0.05) return False diff --git a/tests/test_litellm/proxy/discovery_endpoints/test_agent_skills_archive.py b/tests/test_litellm/proxy/discovery_endpoints/test_agent_skills_archive.py new file mode 100644 index 00000000000..dd5ac230aac --- /dev/null +++ b/tests/test_litellm/proxy/discovery_endpoints/test_agent_skills_archive.py @@ -0,0 +1,106 @@ +import hashlib +import io +import zipfile + +from litellm.proxy.discovery_endpoints.agent_skills_archive import ( + MAX_ARCHIVE_ENTRIES, + build_skill_archive, +) + +MANIFEST = b"""--- +name: pdf-summarizer +description: Summarize a PDF into an executive brief. +--- + +Read the PDF, then write the brief. +""" + + +def zip_bytes(files: dict[str, bytes]) -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive: + for name, content in files.items(): + archive.writestr(name, content) + return buffer.getvalue() + + +def entries_of(content: bytes) -> dict[str, bytes]: + with zipfile.ZipFile(io.BytesIO(content)) as archive: + return {name: archive.read(name) for name in archive.namelist()} + + +def test_single_top_level_folder_is_stripped_so_skill_md_sits_at_the_root(): + archive = build_skill_archive( + zip_bytes( + { + "pdf-summarizer/SKILL.md": MANIFEST, + "pdf-summarizer/reference.md": b"page citations", + "pdf-summarizer/scripts/extract.py": b"print('hi')", + } + ) + ) + + assert archive is not None + assert entries_of(archive.content) == { + "SKILL.md": MANIFEST, + "reference.md": b"page citations", + "scripts/extract.py": b"print('hi')", + } + + +def test_digest_covers_the_repacked_bytes_and_is_stable_across_builds(): + upload = zip_bytes({"pdf-summarizer/SKILL.md": MANIFEST, "pdf-summarizer/reference.md": b"page citations"}) + + first = build_skill_archive(upload) + second = build_skill_archive(upload) + + assert first is not None and second is not None + assert first.digest == f"sha256:{hashlib.sha256(first.content).hexdigest()}" + assert first.content == second.content + + +def test_an_upload_that_is_already_flat_keeps_every_file_where_it_is(): + archive = build_skill_archive(zip_bytes({"SKILL.md": MANIFEST, "reference.md": b"page citations"})) + + assert archive is not None + assert sorted(entries_of(archive.content)) == ["SKILL.md", "reference.md"] + + +def test_manifest_frontmatter_supplies_the_declared_name_and_description(): + archive = build_skill_archive(zip_bytes({"pdf-summarizer/SKILL.md": MANIFEST})) + + assert archive is not None + assert archive.declared_name == "pdf-summarizer" + assert archive.declared_description == "Summarize a PDF into an executive brief." + + +def test_a_manifest_without_frontmatter_declares_nothing(): + archive = build_skill_archive(zip_bytes({"pdf-summarizer/SKILL.md": b"just prose, no frontmatter"})) + + assert archive is not None + assert archive.declared_name is None + assert archive.declared_description is None + + +def test_a_manifest_buried_below_the_stripped_folder_is_not_installable(): + assert build_skill_archive(zip_bytes({"pdf-summarizer/nested/SKILL.md": MANIFEST})) is None + + +def test_an_upload_with_no_manifest_is_not_installable(): + assert build_skill_archive(zip_bytes({"pdf-summarizer/reference.md": b"page citations"})) is None + + +def test_a_non_zip_upload_is_not_installable(): + assert build_skill_archive(MANIFEST) is None + + +def test_a_path_traversal_entry_is_not_installable(): + assert build_skill_archive(zip_bytes({"SKILL.md": MANIFEST, "../escape.md": b"nope"})) is None + + +def test_an_upload_over_the_entry_cap_is_not_installable(): + files = {"pdf-summarizer/SKILL.md": MANIFEST} | { + f"pdf-summarizer/file-{index}.md": b"x" for index in range(MAX_ARCHIVE_ENTRIES) + } + + assert build_skill_archive(zip_bytes(files)) is None diff --git a/tests/test_litellm/proxy/discovery_endpoints/test_agent_skills_endpoints.py b/tests/test_litellm/proxy/discovery_endpoints/test_agent_skills_endpoints.py new file mode 100644 index 00000000000..ea889e75ae8 --- /dev/null +++ b/tests/test_litellm/proxy/discovery_endpoints/test_agent_skills_endpoints.py @@ -0,0 +1,211 @@ +import hashlib +import io +import zipfile +from datetime import datetime, timezone + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +import litellm +from litellm.models.skills import LiteLLM_SkillsTable +from litellm.proxy.discovery_endpoints.agent_skills_endpoints import ( + router, + stored_skill, + stored_skills, +) +from litellm.types.proxy.discovery_endpoints.agent_skills_endpoints import ( + AGENT_SKILLS_DISCOVERY_SCHEMA_URL, +) + +WELL_KNOWN_PATHS = ("/.well-known/agent-skills/index.json", "/.well-known/skills/index.json") + +MANIFEST = b"""--- +name: pdf-summarizer +description: Summarize a PDF into an executive brief. +--- + +Read the PDF, then write the brief. +""" + + +def zip_bytes(files: dict[str, bytes]) -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive: + for name, content in files.items(): + archive.writestr(name, content) + return buffer.getvalue() + + +def skill( + skill_id: str, + *, + display_title: str | None = "PDF Summarizer", + description: str | None = None, + files: dict[str, bytes] | None = None, + updated_at: datetime | None = None, +) -> LiteLLM_SkillsTable: + return LiteLLM_SkillsTable( + skill_id=skill_id, + display_title=display_title, + description=description, + file_content=zip_bytes(files if files is not None else {"pdf-summarizer/SKILL.md": MANIFEST}), + updated_at=updated_at, + ) + + +def client_for(*skills: LiteLLM_SkillsTable) -> TestClient: + app = FastAPI() + app.include_router(router) + + def _skills() -> tuple[LiteLLM_SkillsTable, ...]: + return skills + + def _skill(skill_id: str) -> LiteLLM_SkillsTable | None: + return next((candidate for candidate in skills if candidate.skill_id == skill_id), None) + + app.dependency_overrides[stored_skills] = _skills + app.dependency_overrides[stored_skill] = _skill + return TestClient(app) + + +@pytest.fixture +def index_enabled(monkeypatch): + monkeypatch.setattr(litellm, "public_skills_index", True) + + +def test_discovery_is_absent_until_public_skills_index_is_enabled(monkeypatch): + monkeypatch.setattr(litellm, "public_skills_index", False) + client = client_for(skill("litellm_skill_1")) + + for path in WELL_KNOWN_PATHS: + assert client.get(path).status_code == 404 + assert client.get("/v1/skills/litellm_skill_1/archive").status_code == 404 + + +@pytest.mark.parametrize("path", WELL_KNOWN_PATHS) +def test_index_publishes_each_stored_skill_in_the_v0_2_0_shape(index_enabled, path): + client = client_for(skill("litellm_skill_1")) + + body = client.get(path).json() + + assert body["$schema"] == AGENT_SKILLS_DISCOVERY_SCHEMA_URL + assert len(body["skills"]) == 1 + entry = body["skills"][0] + assert entry["name"] == "pdf-summarizer" + assert entry["type"] == "archive" + assert entry["description"] == "Summarize a PDF into an executive brief." + assert entry["url"].endswith("/v1/skills/litellm_skill_1/archive") + assert entry["digest"].startswith("sha256:") + + +def test_index_digest_matches_the_bytes_the_archive_route_serves(index_enabled): + client = client_for(skill("litellm_skill_1")) + + entry = client.get(WELL_KNOWN_PATHS[0]).json()["skills"][0] + downloaded = client.get(entry["url"]) + + assert downloaded.status_code == 200 + assert downloaded.headers["content-type"] == "application/zip" + assert entry["digest"] == f"sha256:{hashlib.sha256(downloaded.content).hexdigest()}" + + +def test_install_name_falls_back_to_the_manifest_name_without_a_display_title(index_enabled): + client = client_for(skill("litellm_skill_1", display_title=None)) + + assert client.get(WELL_KNOWN_PATHS[0]).json()["skills"][0]["name"] == "pdf-summarizer" + + +@pytest.mark.parametrize( + "manifest, stored_description, expected", + [ + (MANIFEST, "registry copy", "Summarize a PDF into an executive brief."), + (b"no frontmatter here", "registry copy", "registry copy"), + (b"no frontmatter here", None, "PDF Summarizer"), + ], +) +def test_description_prefers_the_manifest_then_the_registry_then_the_title( + index_enabled, manifest, stored_description, expected +): + client = client_for( + skill( + "litellm_skill_1", + description=stored_description, + files={"pdf-summarizer/SKILL.md": manifest}, + ) + ) + + assert client.get(WELL_KNOWN_PATHS[0]).json()["skills"][0]["description"] == expected + + +def test_skills_sharing_a_title_get_distinct_install_names(index_enabled): + client = client_for( + skill("litellm_skill_2", files={"pdf-summarizer/SKILL.md": b"second"}), + skill("litellm_skill_1", files={"pdf-summarizer/SKILL.md": b"first"}), + ) + + names = [entry["name"] for entry in client.get(WELL_KNOWN_PATHS[0]).json()["skills"]] + + assert names == ["pdf-summarizer", "pdf-summarizer-2"] + + +def test_uploads_without_a_root_manifest_are_left_out_of_the_index(index_enabled): + client = client_for( + skill("litellm_skill_1"), + skill("litellm_skill_2", files={"pdf-summarizer/reference.md": b"no manifest"}), + ) + + body = client.get(WELL_KNOWN_PATHS[0]).json() + + assert [entry["url"].split("/")[-2] for entry in body["skills"]] == ["litellm_skill_1"] + assert client.get("/v1/skills/litellm_skill_2/archive").status_code == 404 + + +def test_archive_route_404s_for_a_skill_that_does_not_exist(index_enabled): + client = client_for(skill("litellm_skill_1")) + + assert client.get("/v1/skills/litellm_skill_missing/archive").status_code == 404 + + +def test_a_stored_skill_is_repacked_once_per_version(index_enabled): + stamp = datetime(2026, 9, 6, 9, 0, tzinfo=timezone.utc) + first = client_for(skill("litellm_skill_cached", files={"s/SKILL.md": MANIFEST}, updated_at=stamp)) + published = first.get(WELL_KNOWN_PATHS[0]).json()["skills"][0]["digest"] + + unchanged_row = client_for( + skill("litellm_skill_cached", files={"s/SKILL.md": MANIFEST, "s/extra.md": b"rewritten"}, updated_at=stamp) + ) + + assert unchanged_row.get(WELL_KNOWN_PATHS[0]).json()["skills"][0]["digest"] == published + assert hashlib.sha256(unchanged_row.get("/v1/skills/litellm_skill_cached/archive").content).hexdigest() == ( + published.removeprefix("sha256:") + ) + + +def test_a_skill_edited_since_the_last_read_is_republished(index_enabled): + stamp = datetime(2026, 9, 6, 9, 0, tzinfo=timezone.utc) + before = client_for(skill("litellm_skill_edited", files={"s/SKILL.md": MANIFEST}, updated_at=stamp)) + published = before.get(WELL_KNOWN_PATHS[0]).json()["skills"][0]["digest"] + + after = client_for( + skill( + "litellm_skill_edited", + files={"s/SKILL.md": MANIFEST, "s/extra.md": b"rewritten"}, + updated_at=datetime(2026, 9, 6, 10, 0, tzinfo=timezone.utc), + ) + ) + republished = after.get(WELL_KNOWN_PATHS[0]).json()["skills"][0]["digest"] + + assert republished != published + assert hashlib.sha256(after.get("/v1/skills/litellm_skill_edited/archive").content).hexdigest() == ( + republished.removeprefix("sha256:") + ) + + +def test_openapi_declares_the_archive_route_as_a_zip_download(index_enabled): + schema = client_for(skill("litellm_skill_1")).get("/openapi.json").json() + + content = schema["paths"]["/v1/skills/{skill_id}/archive"]["get"]["responses"]["200"]["content"] + + assert "application/zip" in content + assert "application/json" not in content diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_conduct.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_conduct.py new file mode 100644 index 00000000000..323756f8fa0 --- /dev/null +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_conduct.py @@ -0,0 +1,423 @@ +from __future__ import annotations + +import importlib.util +import json +import warnings +from collections.abc import Mapping +from dataclasses import dataclass, field +from typing import Final, Literal + +import httpx +import pytest +import respx +from fastapi import HTTPException + +import litellm +from litellm.integrations.custom_guardrail import CustomGuardrail +from litellm.proxy.guardrails.guardrail_endpoints import get_guardrail_ui_settings, get_provider_specific_params +from litellm.proxy.guardrails.guardrail_hooks.conduct import ( + DEFAULT_TIMEOUT_SECONDS, + ConductGuardrail, + initialize_guardrail, +) +from litellm.proxy.guardrails.guardrail_hooks.conduct.conduct import ( + apply_conduct_guardrail, + binds_unreachable_fallback, + record_decision, + request_payload, +) +from litellm.proxy.guardrails.guardrail_registry import InMemoryGuardrailHandler +from litellm.types.guardrails import Guardrail, GuardrailEventHooks, LitellmParams +from litellm.types.llms.openai import ChatCompletionAssistantMessage +from litellm.types.proxy.guardrails.guardrail_hooks.conduct import ( + ConductGuardrailConfigModel, + ConductGuardrailConfigModelOptionalParams, +) +from litellm.types.utils import GenericGuardrailAPIInputs + +PACKAGE_INSTALLED: Final = importlib.util.find_spec("conduct_litellm_guard") is not None + + +class _RecordingGuardrail(CustomGuardrail): + """Stand-in with the ``conduct_litellm_guard.ConductGuard`` class contract.""" + + @classmethod + def get_supported_event_hooks(cls) -> list[GuardrailEventHooks]: + return [GuardrailEventHooks.pre_call] + + def __init__( + self, + *, + api_url: str | None = None, + agent_token: str | None = None, + workspace_id: str | None = None, + unreachable_fallback: str | None = None, + tool_name: str = "llm_call", + timeout: float = 8.0, + guardrail_name: str | None = None, + event_hook: str | None = None, + default_on: bool = False, + supported_event_hooks: list[GuardrailEventHooks] | None = None, + ) -> None: + super().__init__( + guardrail_name=guardrail_name, + event_hook=event_hook, # pyright: ignore[reportArgumentType] # CustomGuardrail coerces the str at runtime + default_on=default_on, + supported_event_hooks=supported_event_hooks, + ) + self.api_url = api_url + self.agent_token = agent_token + self.workspace_id = workspace_id + self.unreachable_fallback = unreachable_fallback or "fail_closed" + self.tool_name = tool_name + self.timeout = timeout + + +@dataclass(frozen=True, slots=True) +class _Decision: + verdict: str + rule_id: str | None = None + + +class _Blocked(Exception): + def __init__(self, decision: _Decision) -> None: + super().__init__(decision.verdict) + self.decision = decision + + +@dataclass(slots=True) +class _RecordingCheck: + verdict: str + rule_id: str | None = None + calls: list[tuple[Mapping[str, object], str]] = field(default_factory=list) # mutable-ok: test spy + recorded: list[_Decision] = field(default_factory=list) # mutable-ok: test spy + + async def __call__(self, *, data: Mapping[str, object], call_type: str) -> _Decision: + self.calls.append((data, call_type)) + return _Decision(self.verdict, self.rule_id) + + def record(self, decision: _Decision) -> None: + self.recorded.append(decision) + + +async def _bridge( + check: _RecordingCheck, + inputs: GenericGuardrailAPIInputs, + request_data: Mapping[str, object], + input_type: Literal["request", "response"], +) -> GenericGuardrailAPIInputs: + return await apply_conduct_guardrail(inputs, request_data, input_type, check, _Blocked, check.record) + + +def _guardrail_records(request_data: Mapping[str, object]) -> list[tuple[str, object]]: + metadata: Final = request_data["metadata"] + assert isinstance(metadata, dict) + records: Final = metadata["standard_logging_guardrail_information"] + assert isinstance(records, list) + return [(record["guardrail_status"], record["guardrail_response"]) for record in records] + + +def _params(mode: str = "pre_call", **extras: object) -> LitellmParams: + return LitellmParams(guardrail="conduct", mode=mode, api_key="cond_agt_test", **extras) + + +def _guardrail(litellm_params: LitellmParams) -> Guardrail: + return Guardrail(guardrail_name="conduct-guard", litellm_params=litellm_params) + + +def _init(litellm_params: LitellmParams) -> _RecordingGuardrail: + callback: Final = initialize_guardrail( + litellm_params, _guardrail(litellm_params), guardrail_cls=_RecordingGuardrail + ) + assert isinstance(callback, _RecordingGuardrail) + return callback + + +@pytest.fixture(autouse=True) +def _isolate_callbacks(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(litellm, "callbacks", []) + + +def test_maps_typed_fields_and_extras_onto_plugin_kwargs() -> None: + callback: Final = _init( + _params( + api_base="https://guard.example.test", + unreachable_fallback="fail_open", + timeout="3", + workspace_id="ws_123", + tool_name="workflow", + default_on=True, + ) + ) + + assert callback.api_url == "https://guard.example.test" + assert callback.agent_token == "cond_agt_test" + assert callback.unreachable_fallback == "fail_open" + assert callback.timeout == 3.0 + assert callback.workspace_id == "ws_123" + assert callback.tool_name == "workflow" + assert callback.guardrail_name == "conduct-guard" + assert callback.event_hook == "pre_call" + assert callback.default_on is True + assert litellm.callbacks == [callback] + + +def test_defaults_when_optional_config_is_omitted() -> None: + callback: Final = _init(_params()) + + assert callback.unreachable_fallback == "fail_closed" + assert callback.timeout == DEFAULT_TIMEOUT_SECONDS + assert callback.workspace_id is None + assert callback.tool_name == "llm_call" + + +def test_ui_form_defaults_match_what_the_initializer_forwards() -> None: + optional: Final = ConductGuardrailConfigModelOptionalParams() + model: Final = ConductGuardrailConfigModel(api_key="cond_agt_test") + callback: Final = _init( + _params(**{**model.model_dump(exclude={"api_key", "optional_params"}), **optional.model_dump()}) + ) + + assert callback.api_url == model.api_base + assert callback.unreachable_fallback == optional.unreachable_fallback + assert callback.timeout == optional.timeout + assert callback.workspace_id == optional.workspace_id + assert callback.tool_name == optional.tool_name + + +@pytest.mark.asyncio +async def test_ui_offers_conduct_fields_without_the_package() -> None: + assert ConductGuardrail.get_config_model() is ConductGuardrailConfigModel + + fields: Final = (await get_provider_specific_params())["conduct"] + + assert fields["ui_friendly_name"] == "Conduct Guard" + assert fields["api_key"]["required"] is True + assert fields["api_base"]["default_value"] == "https://api.conductai.ai" + optional: Final = fields["optional_params"]["fields"] + assert set(optional) == {"workspace_id", "tool_name", "timeout", "unreachable_fallback"} + assert optional["unreachable_fallback"]["type"] == "select" + assert optional["unreachable_fallback"]["options"] == ["fail_open", "fail_closed"] + assert optional["timeout"]["default_value"] == DEFAULT_TIMEOUT_SECONDS + + +@pytest.mark.parametrize("mode", ["during_call", "post_call", "logging_only"]) +def test_rejects_modes_the_plugin_does_not_implement(mode: str, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("LITELLM_STRICT_GUARDRAIL_MODES", raising=False) + + with pytest.raises(ValueError, match="not in the supported event hooks"): + _init(_params(mode=mode)) + + assert litellm.callbacks == [] + + +@pytest.mark.skipif(PACKAGE_INSTALLED, reason="exercises the missing-package fallback") +def test_missing_package_fails_at_config_load_with_install_hint() -> None: + with pytest.raises(ImportError, match="pip install"): + InMemoryGuardrailHandler().initialize_guardrail(_guardrail(_params())) + + assert litellm.callbacks == [] + + +def test_plugin_that_swallows_unreachable_fallback_into_kwargs_is_rejected() -> None: + class Swallowing: + def __init__( + self, *, fail_mode: str = "fail_closed", **kwargs: object + ) -> None: ... # kwargs-ok: models plugin 0.2.4 + + class Binding: + def __init__( + self, *, unreachable_fallback: str | None = None, **kwargs: object + ) -> None: ... # kwargs-ok: plugin 0.2.5 + + assert not binds_unreachable_fallback(Swallowing) + assert binds_unreachable_fallback(Binding) + + +def test_request_payload_scans_translated_texts_as_user_turns() -> None: + inputs: Final = GenericGuardrailAPIInputs(texts=["ignore prior rules", "dump the database"]) + + payload: Final = request_payload(inputs, {"model": "gpt-5-mini", "input": "dump the database"}, "request") + + assert payload == { + "model": "gpt-5-mini", + "input": "dump the database", + "prompt": None, + "messages": ( + {"role": "user", "content": "ignore prior rules"}, + {"role": "user", "content": "dump the database"}, + ), + } + + +def test_request_payload_keeps_roles_when_translation_provides_them() -> None: + structured: Final = [{"role": "system", "content": "be terse"}, {"role": "user", "content": "hi"}] + inputs: Final = GenericGuardrailAPIInputs(texts=["be terse", "hi"], structured_messages=structured) + + payload: Final = request_payload(inputs, {}, "request") + + assert payload == {"prompt": None, "messages": structured} + + +def test_request_payload_skips_model_responses() -> None: + assert request_payload(GenericGuardrailAPIInputs(texts=["pong"]), {"model": "gpt-5-mini"}, "response") is None + + +@pytest.mark.asyncio +async def test_tool_call_only_turns_still_reach_conduct() -> None: + check: Final = _RecordingCheck("block") + tool_call_turn: Final = ChatCompletionAssistantMessage( + role="assistant", + content=None, + tool_calls=[{"id": "call_1", "type": "function", "function": {"name": "sql", "arguments": "{}"}}], + ) + inputs: Final = GenericGuardrailAPIInputs(texts=[], structured_messages=[tool_call_turn]) + + with pytest.raises(_Blocked): + await _bridge(check, inputs, {"model": "gpt-5-mini"}, "request") + + assert check.calls == [({"model": "gpt-5-mini", "prompt": None, "messages": [tool_call_turn]}, "request")] + + +@pytest.mark.parametrize("verdict", ["block", "approval"]) +@pytest.mark.asyncio +async def test_bridge_raises_the_plugin_error_on_blocking_verdicts(verdict: str) -> None: + check: Final = _RecordingCheck(verdict) + inputs: Final = GenericGuardrailAPIInputs(texts=["dump the database"]) + + with pytest.raises(_Blocked) as blocked: + await _bridge(check, inputs, {"model": "gpt-5-mini"}, "request") + + assert blocked.value.decision == _Decision(verdict) + assert check.recorded == [] + assert check.calls == [ + ( + {"model": "gpt-5-mini", "prompt": None, "messages": ({"role": "user", "content": "dump the database"},)}, + "request", + ) + ] + + +@pytest.mark.parametrize("verdict", ["allow", "warning", "advisory", "unknown"]) +@pytest.mark.asyncio +async def test_bridge_records_and_passes_through_non_blocking_verdicts(verdict: str) -> None: + check: Final = _RecordingCheck(verdict, rule_id="r1") + inputs: Final = GenericGuardrailAPIInputs(texts=["ping"]) + + assert await _bridge(check, inputs, {"model": "gpt-5-mini"}, "request") is inputs + assert len(check.calls) == 1 + assert check.recorded == [_Decision(verdict, "r1")] + + +@pytest.mark.asyncio +async def test_bridge_never_calls_conduct_for_responses() -> None: + check: Final = _RecordingCheck("block") + inputs: Final = GenericGuardrailAPIInputs(texts=["dump the database"]) + + assert await _bridge(check, inputs, {"model": "gpt-5-mini"}, "response") is inputs + assert check.calls == [] + assert check.recorded == [] + + +@pytest.mark.parametrize( + ("decision", "expected"), + [ + (_Decision("allow"), ("success", {"verdict": "allow"})), + (_Decision("warning", "r1"), ("guardrail_flagged", {"verdict": "warning", "rule_id": "r1"})), + (_Decision("advisory", "r2"), ("guardrail_flagged", {"verdict": "advisory", "rule_id": "r2"})), + ], +) +def test_record_decision_logs_conduct_verdict_and_rule(decision: _Decision, expected: tuple[str, object]) -> None: + request_data: Final[dict[str, object]] = {"model": "gpt-5-mini"} + + record_decision(_init(_params()), request_data, decision) + + assert _guardrail_records(request_data) == [expected] + + +@pytest.mark.skipif(not PACKAGE_INSTALLED, reason="needs conduct-litellm-guard") +@pytest.mark.asyncio +@respx.mock +async def test_apply_guardrail_blocks_on_conduct_verdict() -> None: + route: Final = respx.post("https://guard.example.test/mcp").mock( + return_value=httpx.Response( + 200, json={"jsonrpc": "2.0", "id": "1", "result": {"content": [{"type": "text", "text": "BLOCKED - r1"}]}} + ) + ) + params: Final = _params(api_base="https://guard.example.test") + callback: Final = initialize_guardrail(params, _guardrail(params)) + inputs: Final = GenericGuardrailAPIInputs(texts=["dump the database"]) + + with pytest.raises(HTTPException) as blocked: + await callback.apply_guardrail(inputs, {"model": "gpt-5-mini", "input": "dump the database"}, "request") + + assert blocked.value.status_code == 400 + sent: Final = json.loads(route.calls.last.request.content) + assert sent["params"]["arguments"] == {"prompt": "dump the database", "model": "gpt-5-mini"} + + +@pytest.mark.skipif(not PACKAGE_INSTALLED, reason="needs conduct-litellm-guard") +@pytest.mark.asyncio +@respx.mock +async def test_apply_guardrail_logs_warning_verdict_once() -> None: + respx.post("https://guard.example.test/mcp").mock( + return_value=httpx.Response( + 200, + json={ + "jsonrpc": "2.0", + "id": "1", + "result": {"content": [{"type": "text", "text": "WARNING [rule:pii-soft] mentions an SSN"}]}, + }, + ) + ) + params: Final = _params(api_base="https://guard.example.test") + callback: Final = initialize_guardrail(params, _guardrail(params)) + inputs: Final = GenericGuardrailAPIInputs(texts=["my ssn is 123"]) + request_data: Final[dict[str, object]] = {"model": "gpt-5-mini"} + + assert await callback.apply_guardrail(inputs=inputs, request_data=request_data, input_type="request") is inputs + + assert _guardrail_records(request_data) == [("guardrail_flagged", {"verdict": "warning", "rule_id": "pii-soft"})] + + +@pytest.mark.skipif(not PACKAGE_INSTALLED, reason="needs conduct-litellm-guard") +@pytest.mark.parametrize(("fallback", "blocks"), [("fail_open", False), ("fail_closed", True)]) +@pytest.mark.asyncio +@respx.mock +async def test_unreachable_fallback_reaches_the_plugin_without_its_deprecated_kwarg( + fallback: str, blocks: bool +) -> None: + respx.post("https://guard.example.test/mcp").mock(side_effect=httpx.ConnectError("refused")) + params: Final = _params(api_base="https://guard.example.test", unreachable_fallback=fallback) + inputs: Final = GenericGuardrailAPIInputs(texts=["ping"]) + + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + callback: Final = initialize_guardrail(params, _guardrail(params)) + + if blocks: + with pytest.raises(HTTPException): + await callback.apply_guardrail(inputs, {"model": "gpt-5-mini"}, "request") + return + assert await callback.apply_guardrail(inputs, {"model": "gpt-5-mini"}, "request") is inputs + + +@pytest.mark.skipif(not PACKAGE_INSTALLED, reason="needs conduct-litellm-guard") +def test_config_loads_conduct_and_rejects_modes_the_plugin_lacks() -> None: + handler: Final = InMemoryGuardrailHandler() + + loaded: Final = handler.initialize_guardrail(_guardrail(_params())) + assert loaded is not None + assert loaded["litellm_params"].guardrail == "conduct" + assert [type(callback) for callback in litellm.callbacks] == [ConductGuardrail] + + with pytest.raises(ValueError, match="not in the supported event hooks"): + handler.initialize_guardrail(_guardrail(_params(mode="during_call"))) + + +@pytest.mark.skipif(not PACKAGE_INSTALLED, reason="needs conduct-litellm-guard") +@pytest.mark.asyncio +async def test_ui_only_offers_pre_call_for_conduct() -> None: + settings: Final = await get_guardrail_ui_settings() + + assert settings.supported_modes_by_provider["conduct"] == ["pre_call"] diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/unified_guardrails/test_unified_guardrail.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/unified_guardrails/test_unified_guardrail.py index 3a7ae7aba61..2932373c77e 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/unified_guardrails/test_unified_guardrail.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/unified_guardrails/test_unified_guardrail.py @@ -1,6 +1,8 @@ """Tests for unified guardrail.""" import logging +from types import SimpleNamespace +from typing import Final import pytest @@ -19,14 +21,14 @@ from litellm.llms.base_llm.guardrail_translation.utils import ( openai_messages_without_system, openai_messages_without_tool, ) +from litellm.llms.base_llm.ocr.transformation import OCRPage, OCRResponse +from litellm.llms.mistral.ocr.guardrail_translation.handler import OCRHandler from litellm.llms.openai.chat.guardrail_translation.handler import ( OpenAIChatCompletionsHandler, ) from litellm.llms.openai.responses.guardrail_translation.handler import ( OpenAIResponsesHandler, ) -from litellm.llms.base_llm.ocr.transformation import OCRPage, OCRResponse -from litellm.llms.mistral.ocr.guardrail_translation.handler import OCRHandler from litellm.proxy._experimental.mcp_server.guardrail_translation.handler import ( MCPGuardrailTranslationHandler, ) @@ -644,6 +646,64 @@ class TestUnifiedLLMGuardrails: class TestOCRGuardrailE2E: """End-to-end tests: UnifiedLLMGuardrails -> OCRHandler.""" + @pytest.mark.asyncio + @pytest.mark.parametrize("call_type", [CallTypes.ocr, CallTypes.aocr, CallTypes.aresponses]) + async def test_post_call_logging_fallback_is_limited_to_ocr(self, call_type: CallTypes) -> None: + guardrail: Final = RecordingGuardrail() + response: Final = ( + TestUnifiedLLMGuardrails.TestResponsesRouteAliases._responses_api_response() + if call_type == CallTypes.aresponses + else OCRResponse(model="mistral-ocr-latest", pages=[OCRPage(index=0, markdown="Scan this page")]) + ) + + result: Final = await UnifiedLLMGuardrails().async_post_call_success_hook( + data={ + "guardrail_to_apply": guardrail, + "litellm_logging_obj": SimpleNamespace(call_type=call_type.value), + }, + user_api_key_dict=UserAPIKeyAuth(), + response=response, + ) + + assert result is response + if call_type in (CallTypes.ocr, CallTypes.aocr): + assert len(guardrail.apply_calls) == 1 + assert guardrail.apply_calls[0]["inputs"]["texts"] == ["Scan this page"] + else: + assert guardrail.apply_calls == [] + + @pytest.mark.asyncio + @pytest.mark.parametrize("request_route", [None, "/v1/chat/completions"]) + async def test_ocr_logging_fallback_preserves_route_and_response_precedence( + self, request_route: str | None, monkeypatch: pytest.MonkeyPatch + ) -> None: + from litellm.types.utils import ModelResponse + + _patch_translation_mappings( + monkeypatch, + { + CallTypes.completion: OpenAIChatCompletionsHandler, + CallTypes.acompletion: OpenAIChatCompletionsHandler, + CallTypes.aocr: OCRHandler, + }, + ) + guardrail: Final = RecordingGuardrail() + response: Final = ModelResponse(choices=[{"message": {"role": "assistant", "content": "Chat output"}}]) + + result: Final = await guardrail.async_post_call_success_deployment_hook( + request_data={ + "guardrails": [guardrail.guardrail_name], + "user_api_key_request_route": request_route, + "litellm_logging_obj": SimpleNamespace(call_type=CallTypes.aocr.value), + }, + response=response, + call_type=CallTypes.aocr, + ) + + assert result is response + assert len(guardrail.apply_calls) == 1 + assert guardrail.apply_calls[0]["inputs"]["texts"] == ["Chat output"] + @pytest.mark.asyncio async def test_pre_call_hook_invokes_ocr_handler_for_input(self): """ diff --git a/tests/test_litellm/proxy/guardrails/test_deferred_guardrail_logging.py b/tests/test_litellm/proxy/guardrails/test_deferred_guardrail_logging.py index 8fde4cc9d5e..c550a0a41d2 100644 --- a/tests/test_litellm/proxy/guardrails/test_deferred_guardrail_logging.py +++ b/tests/test_litellm/proxy/guardrails/test_deferred_guardrail_logging.py @@ -15,7 +15,8 @@ Streaming: CSW.__anext__ stores args on logging_obj at stream end. """ import asyncio -from typing import Any +import logging +from typing import Any, Final from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -297,6 +298,38 @@ async def test_no_flag_fires_create_task_normally(): # --------------------------------------------------------------------------- +@pytest.mark.parametrize("call_type", ["ocr", "aocr", "completion", "acompletion", "embedding", "responses"]) +@pytest.mark.parametrize("exception_raised", [False, True]) +def test_native_pending_logging_is_released_only_for_ocr(call_type: str, exception_raised: bool) -> None: + pending: Final = MagicMock() + enqueue: Final = MagicMock() + logger: Final = MagicMock( + call_type=call_type, + _native_pending_logging=pending, + _enqueue_deferred_logging=enqueue, + ) + + ProxyBaseLLMRequestProcessing._flush_deferred_async_logging( + logging_obj=logger, + exception_raised=exception_raised, + ) + ProxyBaseLLMRequestProcessing._flush_deferred_async_logging( + logging_obj=logger, + exception_raised=exception_raised, + ) + + if call_type in ("ocr", "aocr"): + pending.release.assert_called_once_with(not exception_raised) + assert logger._native_pending_logging is None + else: + pending.release.assert_not_called() + assert logger._native_pending_logging is pending + if exception_raised: + enqueue.assert_not_called() + else: + enqueue.assert_called_once_with() + + def test_flush_deferred_async_logging_fires_on_success(): """ Happy path: with no exception, the production flush helper invokes the @@ -1390,7 +1423,7 @@ class TestArmDeferredStreamDispatch: async def test_native_stream_closure_enqueues_single_coroutine(self): from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER - logging_obj, _ = self._dispatch_recording_logging_obj() + logging_obj, recorded = self._dispatch_recording_logging_obj() async def _agen(): yield b"x" @@ -1401,20 +1434,89 @@ class TestArmDeferredStreamDispatch: user_api_key_dict=MagicMock(), logging_obj=logging_obj, ) - closure = logging_obj._on_deferred_stream_complete - assert closure is not None + assert logging_obj._on_deferred_stream_complete is not None async def _logging_coroutine(): return None coro = _logging_coroutine() + logging_obj._deferred_stream_complete_args = (coro,) with patch.object( # test-quality-ok: GLOBAL_LOGGING_WORKER is a process-global singleton with no injection seam GLOBAL_LOGGING_WORKER, "ensure_initialized_and_enqueue" ) as mock_enqueue: - await closure(coro) + ProxyLogging._fire_deferred_stream_logging({"litellm_logging_obj": logging_obj}) + await asyncio.sleep(0) mock_enqueue.assert_called_once_with(async_coroutine=coro) + assert recorded == {} coro.close() + @pytest.mark.asyncio + @pytest.mark.parametrize("route_type", ["anthropic_messages", "aresponses"]) + async def test_raw_generator_stream_storing_csw_arg_shape_dispatches_success(self, route_type): + """Bridged /v1/messages returns AnthropicStreamWrapper's plain SSE + generator, which shares its inner CustomStreamWrapper's logging_obj and + so stores (assembled_response, cache_hit). The closure armed for a raw + generator must accept that shape too, or _fire_deferred_stream_logging + raises TypeError and the request loses its spend log and callbacks.""" + from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER + + logging_obj, recorded = self._dispatch_recording_logging_obj() + + async def _agen(): + yield b"x" + + self._processor()._arm_deferred_stream_dispatch( + response=_agen(), + route_type=route_type, + user_api_key_dict=MagicMock(), + logging_obj=logging_obj, + ) + + assembled = object() + logging_obj._deferred_stream_complete_args = (assembled, True) + with patch.object( # test-quality-ok: GLOBAL_LOGGING_WORKER is a process-global singleton with no injection seam + GLOBAL_LOGGING_WORKER, "ensure_initialized_and_enqueue" + ) as mock_enqueue: + ProxyLogging._fire_deferred_stream_logging({"litellm_logging_obj": logging_obj}) + await asyncio.sleep(0) + + mock_enqueue.assert_not_called() + assert recorded["result"] is assembled + assert recorded["cache_hit"] is True + assert recorded["prefer_async_handlers"] is True + + @pytest.mark.asyncio + @pytest.mark.parametrize("stored_args", [(object(),), (object(), object(), object())]) + async def test_raw_generator_stream_with_unknown_arg_shape_logs_and_drops(self, stored_args, caplog): + from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER + + logging_obj, recorded = self._dispatch_recording_logging_obj() + + async def _agen(): + yield b"x" + + self._processor()._arm_deferred_stream_dispatch( + response=_agen(), + route_type="anthropic_messages", + user_api_key_dict=MagicMock(), + logging_obj=logging_obj, + ) + + logging_obj._deferred_stream_complete_args = stored_args + with ( + patch.object( # test-quality-ok: GLOBAL_LOGGING_WORKER is a process-global singleton with no injection seam + GLOBAL_LOGGING_WORKER, "ensure_initialized_and_enqueue" + ) as mock_enqueue, + caplog.at_level(logging.ERROR, logger="LiteLLM Proxy"), + ): + ProxyLogging._fire_deferred_stream_logging({"litellm_logging_obj": logging_obj}) + await asyncio.sleep(0) + + mock_enqueue.assert_not_called() + assert recorded == {} + dropped = [r for r in caplog.records if r.getMessage().startswith("Deferred stream logging dropped")] + assert len(dropped) == 1 + @pytest.mark.asyncio async def test_csw_closure_routes_through_deferred_stream_guardrails(self, monkeypatch): from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper diff --git a/tests/test_litellm/proxy/hooks/test_key_management_event_hooks.py b/tests/test_litellm/proxy/hooks/test_key_management_event_hooks.py index 860fb762450..1aa9382f3fe 100644 --- a/tests/test_litellm/proxy/hooks/test_key_management_event_hooks.py +++ b/tests/test_litellm/proxy/hooks/test_key_management_event_hooks.py @@ -5,6 +5,8 @@ Validates that email and secret manager operations are independent and non-block """ import asyncio +import json +from typing import Final from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -475,7 +477,7 @@ class TestRotateVirtualKeyInSecretManager: class TestKeyUpdatedAuditLogObjectId: """Tests that /key/update audit logs never store the raw virtual key (issue #31620).""" - async def _run_updated_hook_and_capture_audit_log(self, request_key: str): + async def _run_updated_hook_and_capture_audit_log(self, request_key: str, detach_project: bool = False): import asyncio from litellm.proxy._types import ( @@ -493,6 +495,11 @@ class TestKeyUpdatedAuditLogObjectId: existing_key_row = LiteLLM_VerificationToken( token=hash_token("sk-raw-test-key-31620"), key_name="sk-...1620", + project_id="project-orbit", + ) + + data: Final = UpdateKeyRequest( + key=request_key, max_budget=2000.0, **({"project_id": None} if detach_project else {}) ) with ( @@ -503,7 +510,7 @@ class TestKeyUpdatedAuditLogObjectId: ), ): await KeyManagementEventHooks.async_key_updated_hook( - data=UpdateKeyRequest(key=request_key, max_budget=2000.0), + data=data, existing_key_row=existing_key_row, response=MagicMock(), user_api_key_dict=UserAPIKeyAuth(api_key="sk-admin-key", user_id="admin"), @@ -530,13 +537,22 @@ class TestKeyUpdatedAuditLogObjectId: assert raw_key not in str(audit_row.updated_values) assert raw_key not in str(audit_row.before_value) + @pytest.mark.parametrize("detach_project", [False, True]) @pytest.mark.asyncio - async def test_update_audit_log_passes_through_hashed_key(self): + async def test_update_audit_log_passes_through_hashed_key(self, detach_project: bool): """An already-hashed token sent to /key/update is stored unchanged.""" from litellm.proxy.utils import hash_token hashed_key = hash_token("sk-raw-test-key-31620") - audit_row = await self._run_updated_hook_and_capture_audit_log(request_key=hashed_key) + audit_row: Final = await self._run_updated_hook_and_capture_audit_log( + request_key=hashed_key, detach_project=detach_project, + ) assert audit_row.object_id == hashed_key + updated_values: Final = json.loads(audit_row.updated_values) + assert ("project_id" in updated_values) is detach_project + if detach_project: + assert updated_values["project_id"] is None + assert json.loads(audit_row.before_value)["project_id"] == "project-orbit" + assert updated_values["max_budget"] == 2000.0 diff --git a/tests/test_litellm/proxy/management_endpoints/test_budget_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_budget_endpoints.py index 79d62f772bd..4b6815d7552 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_budget_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_budget_endpoints.py @@ -340,7 +340,8 @@ async def test_update_budget_recomputes_reset_at_when_duration_changes( @pytest.mark.asyncio -async def test_update_budget_preserves_explicit_reset_at(client_and_mocks): +@pytest.mark.parametrize("budget_duration", ["1d", None]) +async def test_update_budget_preserves_explicit_reset_at(client_and_mocks, budget_duration): """An explicit budget_reset_at from the caller always wins over recompute.""" client, _, mock_table = client_and_mocks captured = _capture_update_data(mock_table) @@ -350,7 +351,7 @@ async def test_update_budget_preserves_explicit_reset_at(client_and_mocks): "/budget/update", json={ "budget_id": "budget_explicit_reset", - "budget_duration": "1d", + "budget_duration": budget_duration, "budget_reset_at": explicit.isoformat(), }, ) @@ -377,8 +378,7 @@ async def test_update_budget_without_duration_leaves_reset_at_untouched( @pytest.mark.asyncio -async def test_update_budget_duration_none_does_not_recompute(client_and_mocks): - """Clearing budget_duration (explicit null) must not recompute against a None duration.""" +async def test_update_budget_duration_none_clears_obsolete_reset(client_and_mocks): client, _, mock_table = client_and_mocks captured = _capture_update_data(mock_table) @@ -389,7 +389,7 @@ async def test_update_budget_duration_none_does_not_recompute(client_and_mocks): assert resp.status_code == 200, resp.text assert "budget_duration" in captured and captured["budget_duration"] is None - assert "budget_reset_at" not in captured + assert captured["budget_reset_at"] is None @pytest.mark.asyncio diff --git a/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py b/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py index 6cd900cb041..71896a18f48 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py +++ b/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py @@ -613,6 +613,7 @@ def test_key_metadata_includes_recovered_user_email(): "dirty-key": { "key_alias": "batch-worker", "team_id": "team-1", + "user_id": "alice", "user_email": "alice@example.com", } }, @@ -620,6 +621,7 @@ def test_key_metadata_includes_recovered_user_email(): ) assert meta.key_alias == "batch-worker" + assert meta.user_id == "alice" assert meta.user_email == "alice@example.com" @@ -848,9 +850,11 @@ async def test_aggregated_activity_preserves_metadata_for_deleted_keys(): mock_deleted_key.token = "deleted-key-hash" mock_deleted_key.key_alias = "toto-test-2" mock_deleted_key.team_id = "69cd4b77-b095-4489-8c46-4f2f31d840a2" + mock_deleted_key.user_id = "deleted-key-owner" mock_prisma.db.litellm_deletedverificationtoken = MagicMock() mock_prisma.db.litellm_deletedverificationtoken.find_many = AsyncMock(return_value=[mock_deleted_key]) + mock_prisma.db.litellm_usertable.find_many = AsyncMock(return_value=[]) result = await get_daily_activity_aggregated( prisma_client=mock_prisma, @@ -871,6 +875,7 @@ async def test_aggregated_activity_preserves_metadata_for_deleted_keys(): key_data = chat_endpoint.api_key_breakdown["deleted-key-hash"] assert key_data.metadata.key_alias == "toto-test-2" assert key_data.metadata.team_id == "69cd4b77-b095-4489-8c46-4f2f31d840a2" + assert key_data.metadata.user_id == "deleted-key-owner" assert key_data.metrics.spend == 10.0 diff --git a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py index 92b1ab1586d..0d8b19345f1 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py @@ -2097,6 +2097,22 @@ def test_update_internal_user_params_reset_max_budget_with_none(): assert non_default_values["user_id"] == "test_user" +def test_update_internal_user_params_explicit_duration_clear_overrides_role_default(monkeypatch): + import litellm + + monkeypatch.setattr(litellm, "internal_user_budget_duration", "30d") + data = UpdateUserRequest( + user_id="duration-clear-test", + user_role=LitellmUserRoles.INTERNAL_USER, + budget_duration=None, + ) + + updated = _update_internal_user_params(data_json=data.model_dump(exclude_unset=True), data=data) + + assert updated["budget_duration"] is None + assert updated["budget_reset_at"] is None + + def test_update_internal_user_params_ignores_other_nones(): """ Test that other fields are still filtered out if None diff --git a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py index 65cc23ea67f..646ae43f37a 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py @@ -1,3 +1,4 @@ +from typing import Final import json from datetime import datetime, timedelta, timezone @@ -18129,3 +18130,59 @@ def test_key_generation_check_blank_team_id_uses_personal_permissions(monkeypatc ) is True ) + + +@pytest.mark.asyncio +async def test_project_detachment_preserves_omission_and_other_key_fields(): + existing: Final = LiteLLM_VerificationToken( + token="project-detach-token", project_id="project-orbit", team_id="team-orbit", + organization_id="org-orbit", models=["model-orbit"], max_budget=5, rpm_limit=97, + ) + omitted: Final = await prepare_key_update_data( + data=UpdateKeyRequest(key=existing.token, key_alias="renamed"), existing_key_row=existing, + ) + assert "project_id" not in omitted + cleared: Final = await prepare_key_update_data( + data=UpdateKeyRequest(key=existing.token, project_id=None), existing_key_row=existing, + ) + assert cleared == {"project_id": None, "metadata": {}} + assert existing.project_id == "project-orbit" + + +@pytest.mark.parametrize("project_id", [None, "project-orbit", "project-other", ""]) +@pytest.mark.asyncio +async def test_project_detachment_uses_effective_project_for_validation(project_id: str | None): + existing: Final = LiteLLM_VerificationToken(token="project-detach-token", project_id="project-orbit") + cache: Final = await _cache_with_project("project-orbit", ["model-orbit"]) + data: Final = UpdateKeyRequest(key=existing.token, project_id=project_id, models=["model-other"]) + if project_id is None: + await _validate_update_key_data( + data, existing, UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN), + None, False, MagicMock(), cache, + ) + else: + with pytest.raises(HTTPException) as exc: + await _validate_update_key_data( + data, existing, UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN), + None, False, MagicMock(), cache, + ) + assert exc.value.status_code == 400 + expected: Final = "not in project's allowed models" if project_id == "project-orbit" else "reassignment" + assert expected in str(exc.value.detail) + + +@pytest.mark.asyncio +async def test_key_creator_cannot_detach_project_without_admin_access(): + existing: Final = LiteLLM_VerificationToken( + token="project-detach-token", project_id="project-orbit", user_id="user-orbit", created_by="user-orbit", + ) + database: Final = MagicMock() + database.db.litellm_verificationtoken.find_unique = AsyncMock(return_value=existing) + with pytest.raises(HTTPException) as exc: + await _validate_update_key_data( + UpdateKeyRequest(key=existing.token, project_id=None), existing, + UserAPIKeyAuth(user_id="user-orbit", user_role=LitellmUserRoles.INTERNAL_USER), + None, False, database, UserApiKeyCache(), + ) + assert exc.value.status_code == 403 + assert "Only proxy admins, team admins, or org admins" in str(exc.value.detail) diff --git a/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py index 5325e069813..c66095dc1fd 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py @@ -3,7 +3,7 @@ import asyncio import contextlib import json from collections.abc import Mapping -from typing import Dict, Optional +from typing import Dict, Final, Optional from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -3290,6 +3290,99 @@ def _build_db_model_with_pricing(): ) +class TestUpdateDBModelCompression: + @pytest.mark.parametrize( + "compression_patch, expected", + [ + ( + {}, + { + "auto_router_routing_compression": "routing-compressor", + "auto_router_model_compression": "model-compressor", + }, + ), + ({"auto_router_routing_compression": None}, {"auto_router_model_compression": "model-compressor"}), + ({"auto_router_model_compression": None}, {"auto_router_routing_compression": "routing-compressor"}), + ( + {"auto_router_routing_compression": "none", "auto_router_model_compression": "none"}, + {"auto_router_routing_compression": "none", "auto_router_model_compression": "none"}, + ), + ( + { + "auto_router_routing_compression": "new-compressor", + "auto_router_model_compression": "new-compressor", + }, + { + "auto_router_routing_compression": "new-compressor", + "auto_router_model_compression": "new-compressor", + }, + ), + ], + ) + def test_compression_patch_preserves_omissions_and_explicit_choices( + self, monkeypatch: pytest.MonkeyPatch, compression_patch: dict[str, str | None], expected: dict[str, str] + ): + from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper + from litellm.proxy.management_endpoints.model_management_endpoints import update_db_model + + monkeypatch.setenv("LITELLM_SALT_KEY", "synthetic-compression-salt") + result: Final = update_db_model( + db_model=Deployment( + model_name="synthetic-router", + litellm_params=LiteLLM_Params( + model="auto_router/complexity_router", + auto_router_routing_compression=encrypt_value_helper("routing-compressor"), + auto_router_model_compression=encrypt_value_helper("model-compressor"), + ), + model_info=ModelInfo(id="compression-router"), + ), + updated_patch=updateDeployment.model_validate({"litellm_params": compression_patch}), + ) + params: Final = json.loads(result["litellm_params"]) + assert { + key: decrypt_value_helper(value=val, key=key) + for key, val in params.items() + if key in ("auto_router_routing_compression", "auto_router_model_compression") + } == expected + + def test_explicit_compression_clear_removes_both_saved_overrides(self): + from litellm.proxy.guardrails.auto_router_compression import policy_from_litellm_params + from litellm.proxy.management_endpoints.model_management_endpoints import update_db_model + + db_model: Final = Deployment( + model_name="synthetic-router", + litellm_params=LiteLLM_Params( + model="auto_router/complexity_router", + auto_router_routing_compression="routing-compressor", + auto_router_model_compression="model-compressor", + api_base="http://127.0.0.1:9999/v1", + temperature=0, + ), + model_info=ModelInfo(id="compression-router", team_id="synthetic-team"), + ) + result: Final = update_db_model( + db_model=db_model, + updated_patch=updateDeployment.model_validate( + { + "litellm_params": { + "auto_router_routing_compression": None, + "auto_router_model_compression": None, + "api_base": None, + }, + "model_info": {"team_id": None}, + } + ), + ) + + params: Final = json.loads(result["litellm_params"]) + assert "auto_router_routing_compression" not in params + assert "auto_router_model_compression" not in params + assert policy_from_litellm_params(params) is None + assert params["api_base"] == "http://127.0.0.1:9999/v1" + assert params["temperature"] == 0 + assert json.loads(result["model_info"])["team_id"] == "synthetic-team" + + class TestUpdateDBModelClearPricing: """Sending an explicit `null` for a pricing field must remove it from both `litellm_params` and `model_info` (SPECIAL_MODEL_INFO_PARAMS are mirrored diff --git a/tests/test_litellm/proxy/pass_through_endpoints/llm_provider_handlers/test_anthropic_passthrough_logging_handler.py b/tests/test_litellm/proxy/pass_through_endpoints/llm_provider_handlers/test_anthropic_passthrough_logging_handler.py index d721be62efe..2d7397594aa 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/llm_provider_handlers/test_anthropic_passthrough_logging_handler.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/llm_provider_handlers/test_anthropic_passthrough_logging_handler.py @@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from litellm.integrations.custom_logger import CustomLogger from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.proxy.pass_through_endpoints.llm_provider_handlers.anthropic_passthrough_logging_handler import ( AnthropicPassthroughLoggingHandler, @@ -1551,6 +1552,7 @@ class TestInterruptedStreamOutputTokenRecovery: return f"event: {event}\ndata: {json.dumps(data)}\n\n".encode() _MODEL = "claude-3-5-haiku-20241022" + _PRICED_MODEL = "claude-sonnet-5" _OUTPUT_TEXT = ( "The history of computing spans centuries, beginning with mechanical " "calculators and the abacus, advancing through Charles Babbage's " @@ -1559,7 +1561,7 @@ class TestInterruptedStreamOutputTokenRecovery: "century that gave rise to the modern information age." ) - def _interrupted_chunks(self, *, placeholder_output_tokens: int = 2): + def _interrupted_chunks(self, *, placeholder_output_tokens: int = 2, model: str | None = None): from litellm.proxy.pass_through_endpoints.streaming_handler import ( PassThroughStreamingHandler, ) @@ -1574,7 +1576,7 @@ class TestInterruptedStreamOutputTokenRecovery: "id": "msg_interrupted", "type": "message", "role": "assistant", - "model": self._MODEL, + "model": model or self._MODEL, "content": [], "stop_reason": None, "stop_sequence": None, @@ -1676,6 +1678,81 @@ class TestInterruptedStreamOutputTokenRecovery: # provider count is preserved verbatim. assert usage.completion_tokens == final + @pytest.mark.asyncio + async def test_interrupted_stream_logs_cost_of_recovered_tokens(self): + """ + Regression (LIT-6872): stream_chunk_builder stamps usage.cost and + _hidden_params["response_cost"] from the message_start placeholder before + the interrupted stream is re-tokenized, and the success handler prefers + that hidden cost over the recomputed one. The logged cost must price the + recovered completion tokens, not the placeholder. + """ + import litellm + + class _SuccessRecorder(CustomLogger): + def __init__(self): + super().__init__() + self.success_kwargs: list = [] + + async def async_log_success_event(self, kwargs, response_obj, start_time, end_time): + self.success_kwargs.append(kwargs) + + recorder = _SuccessRecorder() + logging_obj = LiteLLMLoggingObj( + model=self._PRICED_MODEL, + messages=[{"role": "user", "content": "hi"}], + stream=True, + call_type="pass_through_endpoint", + start_time=datetime.now(), + litellm_call_id="lit-6872", + function_id="lit-6872", + dynamic_async_success_callbacks=[recorder], + ) + logging_obj.update_environment_variables( + model=self._PRICED_MODEL, + user="", + optional_params={}, + litellm_params={"custom_llm_provider": "anthropic"}, + custom_llm_provider="anthropic", + ) + placeholder = 1 + handled = AnthropicPassthroughLoggingHandler._handle_logging_anthropic_collected_chunks( + litellm_logging_obj=logging_obj, + passthrough_success_handler_obj=MagicMock(), + url_route="/anthropic/v1/messages", + request_body={"model": self._PRICED_MODEL, "stream": True}, + endpoint_type="messages", + start_time=datetime.now(), + all_chunks=self._interrupted_chunks(placeholder_output_tokens=placeholder, model=self._PRICED_MODEL), + end_time=datetime.now(), + ) + await logging_obj.dispatch_success_handlers( + result=handled["result"], + start_time=datetime.now(), + end_time=datetime.now(), + cache_hit=False, + prefer_async_handlers=True, + **handled["kwargs"], + ) + for _ in range(300): + if recorder.success_kwargs: + break + await asyncio.sleep(0.01) + + assert len(recorder.success_kwargs) == 1 + logged = recorder.success_kwargs[0]["standard_logging_object"] + recovered_tokens = handled["result"].usage.completion_tokens + assert recovered_tokens > placeholder + assert logged["completion_tokens"] == recovered_tokens + prompt_cost, completion_cost = litellm.cost_per_token( + model=self._PRICED_MODEL, prompt_tokens=29, completion_tokens=recovered_tokens + ) + _, placeholder_completion_cost = litellm.cost_per_token( + model=self._PRICED_MODEL, prompt_tokens=29, completion_tokens=placeholder + ) + assert logged["response_cost"] == pytest.approx(prompt_cost + completion_cost) + assert logged["response_cost"] > prompt_cost + placeholder_completion_cost + class TestStreamFalseDeduplication: """ diff --git a/tests/test_litellm/proxy/pass_through_endpoints/llm_provider_handlers/test_openai_passthrough_logging_handler.py b/tests/test_litellm/proxy/pass_through_endpoints/llm_provider_handlers/test_openai_passthrough_logging_handler.py index a3d3ae32169..3d42301ed11 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/llm_provider_handlers/test_openai_passthrough_logging_handler.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/llm_provider_handlers/test_openai_passthrough_logging_handler.py @@ -1832,8 +1832,10 @@ class TestOpenAIPassthroughResponsesStreamingSpendLog: def setup_method(self): self.start_time = datetime.now() self.end_time = datetime.now() + + def _expected_spend(self) -> float: rates = litellm.model_cost[self.MODEL_MAP_KEY] - self.expected_spend = ( + return ( self.INPUT_TOKENS * rates["input_cost_per_token"] + self.OUTPUT_TOKENS * rates["output_cost_per_token"] ) @@ -1914,7 +1916,7 @@ class TestOpenAIPassthroughResponsesStreamingSpendLog: logging_obj.model_call_details["custom_llm_provider"] = "openai" return logging_obj - def test_streamed_responses_passthrough_spend_log_is_priced(self): + def test_streamed_responses_passthrough_spend_log_is_priced(self, local_model_cost_map): """The spend row books the same tokens, spend and `resp_` id as the buffered call.""" result = OpenAIPassthroughLoggingHandler._handle_logging_openai_collected_chunks( litellm_logging_obj=self._logging_obj(), @@ -1942,7 +1944,7 @@ class TestOpenAIPassthroughResponsesStreamingSpendLog: assert spend_log_row["prompt_tokens"] == self.INPUT_TOKENS assert spend_log_row["completion_tokens"] == self.OUTPUT_TOKENS assert spend_log_row["total_tokens"] == self.INPUT_TOKENS + self.OUTPUT_TOKENS - assert spend_log_row["spend"] == self.expected_spend + assert spend_log_row["spend"] == pytest.approx(self._expected_spend()) assert spend_log_row["request_id"] == self.RESPONSE_ID assert spend_log_row["model"] == "gpt-4o-mini" @@ -1967,7 +1969,6 @@ class TestOpenAIPassthroughEmbeddingsSpendLog: def setup_method(self): self.start_time = datetime.now() self.end_time = datetime.now() - self.expected_spend = self.PROMPT_TOKENS * litellm.model_cost[self.MODEL]["input_cost_per_token"] self.response_body = { "object": "list", "data": [{"object": "embedding", "index": 0, "embedding": [0.0, 1.0]}], @@ -1976,6 +1977,9 @@ class TestOpenAIPassthroughEmbeddingsSpendLog: } self.request_body = {"model": self.MODEL, "input": "hello"} + def _expected_spend(self) -> float: + return self.PROMPT_TOKENS * litellm.model_cost[self.MODEL]["input_cost_per_token"] + def _create_mock_httpx_response(self) -> httpx.Response: mock_response = MagicMock(spec=httpx.Response) mock_response.status_code = 200 @@ -2001,7 +2005,7 @@ class TestOpenAIPassthroughEmbeddingsSpendLog: ) return logging_obj - def test_embeddings_passthrough_spend_log_is_priced(self): + def test_embeddings_passthrough_spend_log_is_priced(self, local_model_cost_map): """The dispatched call books prompt tokens and cost onto the spend row.""" dispatched = PassThroughEndpointLogging().normalize_llm_passthrough_logging_payload( httpx_response=self._create_mock_httpx_response(), @@ -2020,7 +2024,7 @@ class TestOpenAIPassthroughEmbeddingsSpendLog: ) assert dispatched["standard_logging_response_object"] is not None - assert dispatched["kwargs"]["response_cost"] == self.expected_spend + assert dispatched["kwargs"]["response_cost"] == pytest.approx(self._expected_spend()) spend_log_row = get_logging_payload( kwargs=dispatched["kwargs"], @@ -2031,7 +2035,7 @@ class TestOpenAIPassthroughEmbeddingsSpendLog: assert spend_log_row["prompt_tokens"] == self.PROMPT_TOKENS assert spend_log_row["total_tokens"] == self.PROMPT_TOKENS - assert spend_log_row["spend"] == self.expected_spend + assert spend_log_row["spend"] == pytest.approx(self._expected_spend()) assert spend_log_row["model"] == self.MODEL assert spend_log_row["custom_llm_provider"] == "openai" assert spend_log_row["request_id"] == self.CALL_ID diff --git a/tests/test_litellm/proxy/policy_engine/test_attachment_registry.py b/tests/test_litellm/proxy/policy_engine/test_attachment_registry.py index 87b1bc56659..fa37a02a37c 100644 --- a/tests/test_litellm/proxy/policy_engine/test_attachment_registry.py +++ b/tests/test_litellm/proxy/policy_engine/test_attachment_registry.py @@ -4,6 +4,7 @@ Unit tests for AttachmentRegistry - tests policy attachment matching. Tests the main entry point: get_attached_policies() """ +import time from datetime import datetime, timezone from unittest.mock import AsyncMock, MagicMock @@ -222,6 +223,21 @@ class TestGetAttachedPolicies: # Should only appear once assert attached.count("multi-policy") == 1 + def test_many_distinct_policies_resolve_in_linear_time(self): + policy_count = 20_000 + registry = AttachmentRegistry() + registry.load_attachments( + [{"policy": f"policy-{index}", "scope": "*"} for index in range(policy_count)] + ) + context = PolicyMatchContext(team_alias="team", key_alias="key", model="gpt-4") + + started = time.perf_counter() + attached = registry.get_attached_policies(context) + elapsed = time.perf_counter() - started + + assert attached == [f"policy-{index}" for index in range(policy_count)] + assert elapsed < 1.0, f"{policy_count} attachments took {elapsed:.2f}s, dedup is no longer one pass" + def test_no_attachments_returns_empty(self): """Test empty attachments returns empty list.""" registry = AttachmentRegistry() 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 e79448d0620..e109b650da7 100644 --- a/tests/test_litellm/proxy/proxy_server/test_proxy_config.py +++ b/tests/test_litellm/proxy/proxy_server/test_proxy_config.py @@ -8,6 +8,7 @@ Pins covered: from __future__ import annotations +import asyncio import json import logging import os @@ -712,22 +713,124 @@ async def test_ProxyConfig__get_config_from_file_missing_path_raises(): # --------------------------------------------------------------------------- -def test_ProxyConfig__process_includes_merges_files(tmp_path): +@pytest.mark.asyncio +async def test_ProxyConfig__process_includes_merges_files(tmp_path): inc = tmp_path / "models.yaml" inc.write_text("model_list:\n - model_name: gpt-4\n") pc = ProxyConfig() cfg = {"include": ["models.yaml"], "model_list": [], "litellm_settings": {}} - result = pc._process_includes(cfg, base_dir=str(tmp_path)) + result = await pc._process_includes(cfg, config_file_path=str(tmp_path / "config.yaml")) assert result == { "model_list": [{"model_name": "gpt-4"}], "litellm_settings": {}, } -def test_ProxyConfig__process_includes_missing_file_raises(tmp_path): +@pytest.mark.asyncio +async def test_ProxyConfig__process_includes_missing_file_raises(tmp_path): pc = ProxyConfig() with pytest.raises(FileNotFoundError): - pc._process_includes({"include": ["nope.yaml"]}, base_dir=str(tmp_path)) + await pc._process_includes({"include": ["nope.yaml"]}, config_file_path=str(tmp_path / "config.yaml")) + + +@pytest.mark.asyncio +async def test_ProxyConfig__process_includes_follows_nested_includes(tmp_path): + (tmp_path / "models.yaml").write_text("include:\n - more_models.yaml\nmodel_list:\n - model_name: first\n") + (tmp_path / "more_models.yaml").write_text("model_list:\n - model_name: second\n") + result = await ProxyConfig()._process_includes( + {"include": ["models.yaml"]}, config_file_path=str(tmp_path / "config.yaml") + ) + assert result == {"model_list": [{"model_name": "first"}, {"model_name": "second"}]} + + +@pytest.mark.asyncio +async def test_ProxyConfig__process_includes_resolves_a_nested_include_next_to_its_own_file(tmp_path): + (tmp_path / "shared").mkdir() + (tmp_path / "shared" / "models.yaml").write_text( + "include:\n - more_models.yaml\nmodel_list:\n - model_name: first\n" + ) + (tmp_path / "shared" / "more_models.yaml").write_text("model_list:\n - model_name: second\n") + (tmp_path / "more_models.yaml").write_text("model_list:\n - model_name: wrong-directory\n") + + result = await ProxyConfig()._process_includes( + {"include": ["shared/models.yaml"]}, config_file_path=str(tmp_path / "config.yaml") + ) + + assert result == {"model_list": [{"model_name": "first"}, {"model_name": "second"}]} + + +@pytest.mark.asyncio +async def test_ProxyConfig__process_includes_still_reads_a_nested_include_left_beside_the_root_config(tmp_path): + (tmp_path / "shared").mkdir() + (tmp_path / "shared" / "models.yaml").write_text( + "include:\n - more_models.yaml\nmodel_list:\n - model_name: first\n" + ) + (tmp_path / "more_models.yaml").write_text("model_list:\n - model_name: second\n") + + result = await ProxyConfig()._process_includes( + {"include": ["shared/models.yaml"]}, config_file_path=str(tmp_path / "config.yaml") + ) + + assert result == {"model_list": [{"model_name": "first"}, {"model_name": "second"}]} + + +@pytest.mark.asyncio +async def test_ProxyConfig__process_includes_names_both_files_when_a_nested_include_matches_two(tmp_path, caplog): + (tmp_path / "shared").mkdir() + (tmp_path / "shared" / "models.yaml").write_text( + "include:\n - more_models.yaml\nmodel_list:\n - model_name: first\n" + ) + (tmp_path / "shared" / "more_models.yaml").write_text("model_list:\n - model_name: next-to-the-declaring-file\n") + (tmp_path / "more_models.yaml").write_text("model_list:\n - model_name: next-to-the-root-config\n") + + with caplog.at_level(logging.WARNING, logger="LiteLLM Proxy"): + result = await ProxyConfig()._process_includes( + {"include": ["shared/models.yaml"]}, config_file_path=str(tmp_path / "config.yaml") + ) + + assert result == {"model_list": [{"model_name": "first"}, {"model_name": "next-to-the-declaring-file"}]} + assert [ + record + for record in caplog.records + if str(tmp_path / "shared" / "more_models.yaml") in record.getMessage() + and str(tmp_path / "more_models.yaml") in record.getMessage() + ] + + +@pytest.mark.asyncio +async def test_ProxyConfig__process_includes_merges_a_shared_file_once(tmp_path): + (tmp_path / "shared.yaml").write_text("model_list:\n - model_name: shared\n") + (tmp_path / "a.yaml").write_text("include:\n - shared.yaml\n") + (tmp_path / "b.yaml").write_text("include:\n - ./shared.yaml\n") + + result = await ProxyConfig()._process_includes( + {"include": ["a.yaml", "b.yaml"]}, config_file_path=str(tmp_path / "config.yaml") + ) + + assert result == {"model_list": [{"model_name": "shared"}]} + + +@pytest.mark.asyncio +async def test_ProxyConfig__process_includes_names_the_file_when_it_is_not_a_mapping(tmp_path): + (tmp_path / "models.yaml").write_text("- model_name: gpt-4\n") + + with pytest.raises(ValueError, match=re.escape(str(tmp_path / "models.yaml"))): + await ProxyConfig()._process_includes( + {"include": ["models.yaml"]}, config_file_path=str(tmp_path / "config.yaml") + ) + + +@pytest.mark.asyncio +async def test_ProxyConfig__process_includes_terminates_on_a_cycle(tmp_path): + (tmp_path / "a.yaml").write_text("include:\n - b.yaml\nmodel_list:\n - model_name: from-a\n") + (tmp_path / "b.yaml").write_text("include:\n - a.yaml\nmodel_list:\n - model_name: from-b\n") + + result = await asyncio.wait_for( + ProxyConfig()._process_includes({"include": ["a.yaml"]}, config_file_path=str(tmp_path / "config.yaml")), + timeout=10, + ) + + assert result == {"model_list": [{"model_name": "from-a"}, {"model_name": "from-b"}]} # --------------------------------------------------------------------------- @@ -1044,6 +1147,31 @@ async def test_ProxyConfig_get_config_loads_from_file(tmp_path, monkeypatch): } +@pytest.mark.asyncio +async def test_ProxyConfig_get_config_from_a_bucket_merges_includes(monkeypatch): + objects = { + "lit6982/config.yaml": { + "include": ["model_config.yaml"], + "general_settings": {"master_key": "sk-1234"}, + }, + "lit6982/model_config.yaml": {"model_list": [{"model_name": "included-model"}]}, + } + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None) + monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", False) + monkeypatch.setattr( + "litellm.proxy.common_utils.load_config_utils.s3_object_reader", + lambda bucket_name: objects.get, + ) + monkeypatch.setenv("LITELLM_CONFIG_BUCKET_NAME", "litellm-configs") + monkeypatch.setenv("LITELLM_CONFIG_BUCKET_OBJECT_KEY", "lit6982/config.yaml") + monkeypatch.setenv("LITELLM_CONFIG_BUCKET_TYPE", "s3") + + cfg = await ProxyConfig().get_config() + + assert cfg["model_list"] == [{"model_name": "included-model"}] + assert "include" not in cfg + + @pytest.mark.asyncio async def test_ProxyConfig_get_config_missing_file_raises(monkeypatch): monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None) diff --git a/tests/test_litellm/proxy/test_prisma_migration.py b/tests/test_litellm/proxy/test_prisma_migration.py index 729adcfb9e0..3fc69b34213 100644 --- a/tests/test_litellm/proxy/test_prisma_migration.py +++ b/tests/test_litellm/proxy/test_prisma_migration.py @@ -1,4 +1,6 @@ import os +import sys +from pathlib import Path from unittest.mock import MagicMock, patch import pytest @@ -64,3 +66,34 @@ class TestPrismaMigration: prisma_migration.main() mock_subprocess_run.assert_not_called() + + @patch("litellm.proxy.prisma_migration.subprocess.run") # test-quality-ok: the spawned argv is the behavior under test + @patch("litellm.proxy.prisma_migration.run_server") # test-quality-ok: run_server boots the whole proxy + def test_prisma_generate_runs_through_the_module_when_the_cli_is_not_on_path( + self, mock_run_server: MagicMock, mock_subprocess_run: MagicMock, tmp_path: Path + ) -> None: + mock_subprocess_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + empty_bin: Path = tmp_path / "emptybin" + empty_bin.mkdir() + + with patch.dict(os.environ, {"PATH": str(empty_bin)}, clear=True): + assert prisma_migration.main() == 0 + + assert mock_subprocess_run.call_args.args[0] == (sys.executable, "-m", "prisma", "generate") + + @patch("litellm.proxy.prisma_migration.subprocess.run") # test-quality-ok: the spawned argv is the behavior under test + @patch("litellm.proxy.prisma_migration.run_server") # test-quality-ok: run_server boots the whole proxy + def test_prisma_generate_runs_the_console_script_when_it_is_on_path( + self, mock_run_server: MagicMock, mock_subprocess_run: MagicMock, tmp_path: Path + ) -> None: + mock_subprocess_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + bin_dir: Path = tmp_path / "bin" + bin_dir.mkdir() + script: Path = bin_dir / "prisma" + script.write_text("#!/bin/sh\nexit 0\n") + script.chmod(0o755) + + with patch.dict(os.environ, {"PATH": str(bin_dir)}, clear=True): + assert prisma_migration.main() == 0 + + assert mock_subprocess_run.call_args.args[0] == ("prisma", "generate") diff --git a/tests/test_litellm/proxy/test_proxy_cli.py b/tests/test_litellm/proxy/test_proxy_cli.py index c76ff189a8a..e25e6a59884 100644 --- a/tests/test_litellm/proxy/test_proxy_cli.py +++ b/tests/test_litellm/proxy/test_proxy_cli.py @@ -1940,6 +1940,66 @@ class TestRunServerDbSetup: use_migrate=False, use_v2_resolver=False ) + @patch("atexit.register") + @patch("litellm.proxy.db.prisma_client.PrismaManager.setup_database") # test-quality-ok: run_server always wires the DB; same isolation as the sibling CLI tests above + @patch("litellm.proxy.db.check_migration.check_prisma_schema_diff") # test-quality-ok: run_server always wires the DB; same isolation as the sibling CLI tests above + @patch("litellm.proxy.db.prisma_client.should_update_prisma_schema") # test-quality-ok: run_server always wires the DB; same isolation as the sibling CLI tests above + def test_migrations_run_when_the_prisma_cli_is_not_on_path( + self, + mock_should_update_schema, + mock_check_schema_diff, + mock_setup_database, + mock_atexit_register, + tmp_path, + capsys, + ): + from litellm.proxy.proxy_cli import run_server + + mock_should_update_schema.return_value = True + empty_bin = tmp_path / "emptybin" + empty_bin.mkdir() + + mock_proxy_module = MagicMock( + app=MagicMock(), + ProxyConfig=MagicMock(), + KeyManagementSettings=MagicMock(), + save_worker_config=MagicMock(), + ) + + clean_env = { + k: v + for k, v in os.environ.items() + if k not in ("DATABASE_URL", "DIRECT_URL") + } + clean_env["DATABASE_URL"] = "postgresql://test:test@localhost:5432/test" + clean_env["PATH"] = str(empty_bin) + + with ( + patch.dict(os.environ, clean_env, clear=True), + patch.dict( + "sys.modules", + { + "proxy_server": mock_proxy_module, + "litellm.proxy.proxy_server": mock_proxy_module, + }, + ), + patch( # test-quality-ok: same isolation as the sibling CLI tests above + "litellm.proxy.proxy_cli.ProxyInitializationHelpers._get_default_unvicorn_init_args" + ) as mock_get_args, + ): + mock_get_args.return_value = { + "app": "litellm.proxy.proxy_server:app", + "host": "localhost", + "port": 8000, + } + + run_server.main(["--local", "--skip_server_startup"], standalone_mode=False) + + assert "prisma CLI is neither on PATH" not in capsys.readouterr().out + mock_setup_database.assert_called_once_with( + use_migrate=True, use_v2_resolver=False + ) + @patch("subprocess.run") @patch("atexit.register") @patch("litellm.proxy.db.prisma_client.PrismaManager.setup_database") diff --git a/tests/test_litellm/rust_bridge/test_configuration.py b/tests/test_litellm/rust_bridge/test_configuration.py index aff9d5acac1..08fa3bfc053 100644 --- a/tests/test_litellm/rust_bridge/test_configuration.py +++ b/tests/test_litellm/rust_bridge/test_configuration.py @@ -52,6 +52,18 @@ def test_resolution_precedence( def test_release_default_remains_disabled() -> None: assert configuration.DEFAULT_RUST_ENABLED is False assert configuration.rust_enabled() is False + assert configuration.rust_ocr_enabled() is True + + +@pytest.mark.parametrize("process", [None, False, True]) +@pytest.mark.parametrize("environment", [None, "0", "1", "off"]) +def test_ocr_configuration(monkeypatch: pytest.MonkeyPatch, process: bool | None, environment: str | None) -> None: + if environment is not None: + monkeypatch.setenv("LITELLM_RUST", environment) + if process is not None: + configuration.rust(process) + + assert configuration.rust_ocr_enabled() is (environment not in {"0", "off"} and process is not False) def test_process_override_wins_over_environment(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/tests/test_litellm/rust_bridge/test_ocr_lifecycle.py b/tests/test_litellm/rust_bridge/test_ocr_lifecycle.py new file mode 100644 index 00000000000..501a4e986c0 --- /dev/null +++ b/tests/test_litellm/rust_bridge/test_ocr_lifecycle.py @@ -0,0 +1,230 @@ +from collections.abc import Generator, Mapping +from typing import Final +from unittest.mock import AsyncMock, Mock + +import pytest + +import litellm +from litellm.llms.base_llm.ocr.transformation import OCRResponse +from litellm.ocr import legacy +from litellm.rust_bridge import bindings, configuration +from litellm.rust_bridge.ocr import LiteLLMOcrRequest +from litellm.rust_bridge.ocr_lifecycle import NATIVE_OCR_LIFECYCLE + + +@pytest.fixture(autouse=True) +def isolated_ocr_configuration(monkeypatch: pytest.MonkeyPatch) -> Generator[None]: + monkeypatch.delenv("LITELLM_RUST", raising=False) + configuration.reset_rust_configuration() + yield + NATIVE_OCR_LIFECYCLE.reset() + configuration.reset_rust_configuration() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("asynchronous", [False, True]) +async def test_unavailable_native_uses_legacy(monkeypatch: pytest.MonkeyPatch, asynchronous: bool) -> None: + response: Final = OCRResponse(pages=[], model="mistral-ocr-latest") + fallback: Final = AsyncMock(return_value=response) if asynchronous else Mock(return_value=response) + monkeypatch.setattr(legacy, "aocr" if asynchronous else "ocr", fallback) + NATIVE_OCR_LIFECYCLE.override(None) + document: Final = {"type": "document_url", "document_url": "https://example.com"} + + result: Final = ( + await litellm.aocr("mistral/mistral-ocr-latest", document, pages=[0]) + if asynchronous + else litellm.ocr("mistral/mistral-ocr-latest", document, pages=[0]) + ) + + assert result is response + fallback.assert_called_once_with("mistral/mistral-ocr-latest", document, pages=[0]) + + +def test_admitted_failure_is_returned_without_replay() -> None: + failure: Final = RuntimeError("admitted") + native: Final = Mock(side_effect=failure) + litellm.rust(True) + NATIVE_OCR_LIFECYCLE.override(native) + try: + with pytest.raises(RuntimeError) as caught: + litellm.ocr("mistral/mistral-ocr-latest", {"type": "document_url", "document_url": "https://example.com"}) + assert caught.value is failure + finally: + NATIVE_OCR_LIFECYCLE.reset() + litellm.rust(None) + assert native.call_count == 1 + + +def test_public_binding_keeps_positional_fields_and_defaults_out_of_native_hook_kwargs() -> None: + document: Final = {"type": "document_url", "document_url": "https://example.com"} + captured: Final = [] + + def native( + request: LiteLLMOcrRequest, + args: tuple[object, ...], + kwargs: Mapping[str, object], + asynchronous: bool, + ) -> OCRResponse: + captured.append((request, args, kwargs, asynchronous)) + return OCRResponse(pages=[], model=request.model) + + litellm.rust(True) + NATIVE_OCR_LIFECYCLE.override(native) + try: + response: Final = litellm.ocr("mistral/mistral-ocr-latest", document) + finally: + NATIVE_OCR_LIFECYCLE.reset() + litellm.rust(None) + + request, call_args, hook_kwargs, asynchronous = captured[0] + assert response.model == "mistral/mistral-ocr-latest" + assert request.model == "mistral/mistral-ocr-latest" + assert request.document is document + assert call_args == ("mistral/mistral-ocr-latest", document) + assert hook_kwargs == {} + assert asynchronous is False + + +def test_public_binding_keeps_keyword_model_and_document_in_native_hook_kwargs() -> None: + document: Final = {"type": "document_url", "document_url": "https://example.com"} + captured: Final = [] + + def native( + request: LiteLLMOcrRequest, + args: tuple[object, ...], + kwargs: Mapping[str, object], + asynchronous: bool, + ) -> OCRResponse: + assert args == () + captured.append(kwargs) + return OCRResponse(pages=[], model=request.model) + + litellm.rust(True) + NATIVE_OCR_LIFECYCLE.override(native) + try: + litellm.ocr(model="mistral/mistral-ocr-latest", document=document) + finally: + NATIVE_OCR_LIFECYCLE.reset() + litellm.rust(None) + + assert captured[0]["model"] == "mistral/mistral-ocr-latest" + assert captured[0]["document"] is document + assert "timeout" not in captured[0] + + +@pytest.mark.parametrize("enabled", [False, True], ids=["flag-disabled", "flag-enabled"]) +def test_public_duplicate_argument_error_does_not_depend_on_native_selection(enabled: bool) -> None: + native: Final = Mock(side_effect=AssertionError("binding errors precede admission")) + document: Final = {"type": "document_url", "document_url": "https://example.com"} + litellm.rust(enabled) + NATIVE_OCR_LIFECYCLE.override(native) + try: + with pytest.raises(TypeError, match=r"ocr\(\) got multiple values for argument 'model'"): + litellm.ocr("mistral/mistral-ocr-latest", document, model="duplicate") + finally: + NATIVE_OCR_LIFECYCLE.reset() + litellm.rust(None) + assert native.call_count == 0 + + +@pytest.mark.parametrize("enabled", [False, True], ids=["flag-disabled", "flag-enabled"]) +def test_public_missing_required_argument_error_does_not_depend_on_native_selection(enabled: bool) -> None: + native: Final = Mock(side_effect=AssertionError("binding errors precede admission")) + litellm.rust(enabled) + NATIVE_OCR_LIFECYCLE.override(native) + try: + with pytest.raises(TypeError, match=r"ocr\(\) missing 1 required positional argument: 'document'"): + litellm.ocr("mistral/mistral-ocr-latest") + finally: + NATIVE_OCR_LIFECYCLE.reset() + litellm.rust(None) + assert native.call_count == 0 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("enabled", [False, True, None]) +async def test_environment_opt_out_never_loads_native( + monkeypatch: pytest.MonkeyPatch, asynchronous: bool, enabled: bool | None +) -> None: + monkeypatch.setenv("LITELLM_RUST", "0") + response: Final = OCRResponse(pages=[], model="mistral-ocr-latest") + fallback: Final = AsyncMock(return_value=response) if asynchronous else Mock(return_value=response) + monkeypatch.setattr(legacy, "aocr" if asynchronous else "ocr", fallback) + load: Final = Mock(side_effect=AssertionError("native must not be loaded")) + monkeypatch.setattr(bindings, "get_native_bridge", load) + litellm.rust(enabled) + document: Final = {"type": "file", "file": b"pdf"} + + result: Final = ( + await litellm.aocr("mistral/mistral-ocr-latest", document, pages=[1]) + if asynchronous + else litellm.ocr("mistral/mistral-ocr-latest", document, pages=[1]) + ) + + assert result is response + fallback.assert_called_once_with("mistral/mistral-ocr-latest", document, pages=[1]) + load.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("environment", [None, "1"]) +async def test_native_is_enabled_by_default( + monkeypatch: pytest.MonkeyPatch, asynchronous: bool, environment: str | None +) -> None: + if environment is not None: + monkeypatch.setenv("LITELLM_RUST", environment) + response: Final = OCRResponse(pages=[], model="mistral-ocr-latest") + native: Final = AsyncMock(return_value=response) if asynchronous else Mock(return_value=response) + NATIVE_OCR_LIFECYCLE.override(native) + fallback: Final = Mock(side_effect=AssertionError("legacy must not run")) + monkeypatch.setattr(legacy, "aocr" if asynchronous else "ocr", fallback) + + result: Final = ( + await litellm.aocr("mistral/mistral-ocr-latest", {}) + if asynchronous + else litellm.ocr("mistral/mistral-ocr-latest", {}) + ) + + assert result is response + assert native.call_count == 1 + fallback.assert_not_called() + + +class Declined(Exception): + pass + + +@pytest.mark.asyncio +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("declined", [False, True]) +async def test_only_native_declines_replay_on_legacy( + monkeypatch: pytest.MonkeyPatch, asynchronous: bool, declined: bool +) -> None: + failure: Final = Declined("unsupported") if declined else RuntimeError("provider already called") + native: Final = AsyncMock(side_effect=failure) if asynchronous else Mock(side_effect=failure) + NATIVE_OCR_LIFECYCLE.override(native) + import importlib + + main: Final = importlib.import_module("litellm.ocr.main") + monkeypatch.setattr(main, "native_exception_types", lambda: (Declined, RuntimeError)) + response: Final = OCRResponse(pages=[], model="mistral-ocr-latest") + fallback: Final = AsyncMock(return_value=response) if asynchronous else Mock(return_value=response) + monkeypatch.setattr(legacy, "aocr" if asynchronous else "ocr", fallback) + document: Final = {"type": "file", "file": b"pdf"} + + async def call() -> object: + if asynchronous: + return await litellm.aocr("mistral/mistral-ocr-latest", document, pages=[0]) + return litellm.ocr("mistral/mistral-ocr-latest", document, pages=[0]) + + if declined: + assert await call() is response + fallback.assert_called_once_with("mistral/mistral-ocr-latest", document, pages=[0]) + else: + with pytest.raises(RuntimeError) as caught: + await call() + assert caught.value is failure + fallback.assert_not_called() + assert native.call_count == 1 diff --git a/tests/test_litellm/test_main.py b/tests/test_litellm/test_main.py index f71225c6fc5..4f7a51eb531 100644 --- a/tests/test_litellm/test_main.py +++ b/tests/test_litellm/test_main.py @@ -874,15 +874,25 @@ def test_responses_api_bridge_check_gpt_5_4_tools_with_default_reasoning_routes_ assert model_info.get("mode") == "responses" -@pytest.mark.parametrize("model_name", ["gpt-5.6-sol", "gpt-5.6-luna", "gpt-5.6-terra"]) +@pytest.mark.parametrize( + "model_name, expected_mode", + [ + pytest.param("gpt-5.6-sol", "responses", id="above-boundary-bridges"), + pytest.param("gpt-5.1", None, id="below-boundary-stays-chat"), + ], +) def test_responses_api_bridge_check_gpt_5_6_tools_with_default_reasoning_routes_to_responses( - monkeypatch, model_name + monkeypatch, model_name, expected_mode ): """ - The whole gpt-5.6 family must bridge on function tools alone. The bridge used to - require an explicit reasoning_effort, so a gpt-5.6 call carrying tools and no effort - was rejected with "Function tools with reasoning_effort are not supported for - gpt-5.6-sol in /v1/chat/completions". + gpt-5.6 must bridge on function tools alone. The bridge used to require an explicit + reasoning_effort, so a gpt-5.6 call carrying tools and no effort was rejected with + "Function tools with reasoning_effort are not supported for gpt-5.6-sol in + /v1/chat/completions". + + Paired with a model below the gpt-5.4 boundary, which must still stay on chat. The + gate parses the version and drops any suffix, so the family members bridge + identically and only the boundary distinguishes behaviour. """ import litellm from litellm.main import responses_api_bridge_check @@ -901,7 +911,7 @@ def test_responses_api_bridge_check_gpt_5_6_tools_with_default_reasoning_routes_ ) assert model == model_name - assert model_info.get("mode") == "responses" + assert model_info.get("mode") == expected_mode def test_responses_api_bridge_check_gpt_5_4_tools_with_reasoning_none_stays_chat(): @@ -3311,15 +3321,19 @@ def local_cost_map(monkeypatch): """The prices these tests assert are the checked-in ones. Setting the environment variable alone does not reload the map, so pin the map itself. - ``get_model_info`` is lru_cached, so pinning ``model_cost`` is not enough on its - own: a cached entry warmed against the network-fetched map keeps its old prices - and ``completion_cost`` bills at those while the assertions read the pinned map. - Clear on the way in and out so entries never leak across tests in either direction.""" + Prices are read through two separate lru_caches, so pinning ``model_cost`` is not + enough on its own: an entry warmed against the network-fetched map keeps its old + prices and billing reads those while the assertions read the pinned map. + ``_invalidate_model_cost_lowercase_map`` clears both caches, where + ``get_model_info.cache_clear`` reaches only one. Invalidate on the way in and out + so entries never leak across tests in either direction.""" + from litellm.utils import _invalidate_model_cost_lowercase_map + monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url="")) - litellm.get_model_info.cache_clear() + _invalidate_model_cost_lowercase_map() yield - litellm.get_model_info.cache_clear() + _invalidate_model_cost_lowercase_map() def test_a_streamed_response_bills_the_usage_the_provider_reported(local_cost_map): diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index c7e46829aba..835e87aff88 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -1,5 +1,6 @@ import asyncio import contextlib +import contextvars import json import logging import os @@ -7,6 +8,7 @@ import queue import threading from datetime import datetime, timedelta, timezone from collections.abc import Iterator +from concurrent.futures import ThreadPoolExecutor from typing import Final from unittest.mock import AsyncMock, MagicMock, patch @@ -60,6 +62,36 @@ from litellm.utils import ( # Adds the parent directory to the system path +def test_non_ocr_wrapper_preserves_logging_executor_and_context(monkeypatch: pytest.MonkeyPatch) -> None: + marker: Final = contextvars.ContextVar("non-ocr-logging-context", default="missing") + token: Final = marker.set("caller-context") + caller_thread: Final = threading.get_ident() + response: Final = object() + logger: Final = MagicMock() + observed: Final = queue.Queue[tuple[object, str, int]]() + + def record_success(result: object, start_time: datetime, end_time: datetime) -> None: + observed.put((result, marker.get(), threading.get_ident())) + + def embedding(**kwargs: object) -> object: + return response + + logger.success_handler.side_effect = record_success + monkeypatch.setattr("litellm.utils.function_setup", MagicMock(return_value=(logger, {}))) + try: + with ThreadPoolExecutor(max_workers=1) as executor: + monkeypatch.setattr("litellm.utils.executor", executor) + result: Final = client(embedding)() + logged_response, context, worker_thread = observed.get_nowait() + assert result is response + assert logged_response is response + assert context == "caller-context" + assert worker_thread != caller_thread + assert observed.empty() + finally: + marker.reset(token) + + def test_cloudflare_model_info_includes_rpm(local_model_cost_map: None) -> None: assert litellm.get_model_info("cloudflare/@cf/meta/llama-3.1-8b-instruct-fp8")["rpm"] == 300 assert litellm.get_model_info("cloudflare/@cf/moonshotai/kimi-k2.6")["rpm"] == 20 @@ -4216,9 +4248,6 @@ def test_deepseek_flash_completion_cost(): _FIREWORKS_MODELS = [ ( "accounts/fireworks/models/glm-5p2", - 1.4e-06, - 4.4e-06, - 1.4e-07, 1048576, 131072, False, @@ -4226,9 +4255,6 @@ _FIREWORKS_MODELS = [ ), ( "accounts/fireworks/models/glm-5p1", - 1.4e-06, - 4.4e-06, - 2.6e-07, 202800, 131072, False, @@ -4236,9 +4262,6 @@ _FIREWORKS_MODELS = [ ), ( "accounts/fireworks/routers/glm-5p1-fast", - 2.8e-06, - 8.8e-06, - 5.2e-07, 202800, 131072, False, @@ -4246,9 +4269,6 @@ _FIREWORKS_MODELS = [ ), ( "accounts/fireworks/models/qwen3p7-plus", - 4e-07, - 1.6e-06, - 8e-08, 262144, 65536, True, @@ -4256,9 +4276,6 @@ _FIREWORKS_MODELS = [ ), ( "accounts/fireworks/models/minimax-m3", - 3e-07, - 1.2e-06, - 6e-08, 512000, 512000, True, @@ -4266,9 +4283,6 @@ _FIREWORKS_MODELS = [ ), ( "accounts/fireworks/models/minimax-m2p7", - 3e-07, - 1.2e-06, - 6e-08, 196608, 196608, False, @@ -4276,9 +4290,6 @@ _FIREWORKS_MODELS = [ ), ( "accounts/fireworks/models/kimi-k2p7-code", - 9.5e-07, - 4e-06, - 1.9e-07, 262144, 32768, True, @@ -4286,9 +4297,6 @@ _FIREWORKS_MODELS = [ ), ( "accounts/fireworks/routers/kimi-k2p7-code-fast", - 1.9e-06, - 8e-06, - 3.8e-07, 262144, 32768, True, @@ -4296,9 +4304,6 @@ _FIREWORKS_MODELS = [ ), ( "accounts/fireworks/models/kimi-k2p6", - 9.5e-07, - 4e-06, - 1.6e-07, 262144, 32768, True, @@ -4306,9 +4311,6 @@ _FIREWORKS_MODELS = [ ), ( "accounts/fireworks/routers/kimi-k2p6-fast", - 2e-06, - 8e-06, - 3e-07, 262144, 32768, True, @@ -4316,9 +4318,6 @@ _FIREWORKS_MODELS = [ ), ( "accounts/fireworks/models/gpt-oss-120b", - 1.5e-07, - 6e-07, - 1.5e-08, 131072, 32768, False, @@ -4326,9 +4325,6 @@ _FIREWORKS_MODELS = [ ), ( "accounts/fireworks/models/gpt-oss-20b", - 7e-08, - 3e-07, - 3.5e-08, 131072, 32768, False, @@ -4336,9 +4332,6 @@ _FIREWORKS_MODELS = [ ), ( "accounts/fireworks/models/deepseek-v4-pro", - 1.74e-06, - 3.48e-06, - 1.45e-07, 1048576, 384000, False, @@ -4346,9 +4339,6 @@ _FIREWORKS_MODELS = [ ), ( "accounts/fireworks/models/deepseek-v4-flash", - 1.4e-07, - 2.8e-07, - 2.8e-08, 1048576, 384000, False, @@ -4380,9 +4370,6 @@ _FIREWORKS_ROUTER_SHORT_FORMS = [ def _assert_fireworks_entry( model_cost, model_path, - expected_input, - expected_output, - expected_cache, expected_max_input, expected_max_output, expected_vision, @@ -4392,9 +4379,9 @@ def _assert_fireworks_entry( 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"] == expected_input - assert info["output_cost_per_token"] == expected_output - assert info["cache_read_input_token_cost"] == expected_cache + assert info["input_cost_per_token"] > 0 + assert info["output_cost_per_token"] > 0 + assert "cache_read_input_token_cost" in info assert info["max_input_tokens"] == expected_max_input assert info["max_output_tokens"] == expected_max_output assert info["max_tokens"] == expected_max_output diff --git a/tests/test_litellm_rust/README.md b/tests/test_litellm_rust/README.md deleted file mode 100644 index 4c117fb846b..00000000000 --- a/tests/test_litellm_rust/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# Rust OCR bridge tests - -This suite covers OCR requests through LiteLLM's compiled Rust extension. OCR behavior tests live under `ocr/`; reusable OCR request, callback, and recording-server fixtures live under `support/` - -A test name identifies the OCR entrypoint or callback under test and its expected observable result. Parameter IDs state the execution mode or credential case. Keep multiple assertions together only when they prove one request, mutation, failure, or callback lifecycle behavior. Record callback observations and assert them after the callback returns because production logging can swallow callback exceptions - -`ocr/test_requests.py` covers provider payloads, file preparation, endpoint and credential resolution, normalized responses, errors, timeouts, and Azure token-provider behavior. `ocr/test_callbacks.py` covers OCR callback inputs, mutations, ordering, context, failure handling, concurrency, and cleanup. `ocr/test_guardrails.py` covers OCR post-call blocking and response replacement. These contract modules call the Rust bridge directly. `ocr/test_dispatch.py` has the single public API dispatch test, covering enabled native dispatch and disabled Python dispatch. `test_ocr.py` is a strict smoke test of the compiled Rust OCR transport - -Run `make test-rust-extension` as the acceptance command. It builds a fresh wheel, installs that wheel into a temporary environment, requires `LITELLM_RUST=1`, and runs this suite with isolated Python imports - -Collection fails when `LITELLM_RUST=1` is set but the compiled `_native` module cannot be imported. The autouse fixture isolates callback and configuration state but does not select a backend. Native contract tests call `litellm.rust_bridge.ocr` directly, while the strict dispatch test explicitly enables and disables Rust and records which OCR entrypoint runs - -The OCR contract modules are non-strict expected failures until the retained callback implementation from #40070 lands. The public dispatch test remains strict. Passing contract cases appear as XPASS so staging coverage stays visible diff --git a/tests/test_litellm_rust/conftest.py b/tests/test_litellm_rust/conftest.py index b0c75d9d2f5..4387ea2e2fd 100644 --- a/tests/test_litellm_rust/conftest.py +++ b/tests/test_litellm_rust/conftest.py @@ -11,7 +11,7 @@ import pytest_asyncio import litellm from litellm import utils -from litellm.litellm_core_utils import litellm_logging +from litellm.litellm_core_utils import litellm_logging, thread_pool_executor from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER from litellm.rust_bridge.configuration import ( # pyright: ignore[reportPrivateUsage] # preserve raw configuration state in test isolation _CONFIGURATION, @@ -29,11 +29,6 @@ CALLBACK_ATTRIBUTES: Final = ( "_async_success_callback", "_async_failure_callback", ) -EXPECTED_FAILURE_REASONS: Final = { - "ocr/test_callbacks.py": "requires the OCR callback lifecycle implementation from #40070", - "ocr/test_guardrails.py": "requires the OCR guardrail lifecycle implementation from #40070", - "ocr/test_requests.py": "requires the OCR request and Azure authentication implementation from #40070", -} def _list_attribute(container: ModuleType, attribute: str) -> list[object]: @@ -76,7 +71,9 @@ async def isolate_ocr_test_state() -> AsyncIterator[None]: stack.enter_context(_rebound(litellm, "cache", None)) # test-quality-ok: isolate process-global cache stack.enter_context(_rebound(_CONFIGURATION, "override", None)) executor: Final = ThreadPoolExecutor(thread_name_prefix="rust-ocr-test-logging") + stack.enter_context(_rebound(litellm_logging, "executor", executor)) stack.enter_context(_rebound(utils, "executor", executor)) + stack.enter_context(_rebound(thread_pool_executor, "executor", executor)) try: yield finally: @@ -94,14 +91,6 @@ def recording_server() -> Generator[RecordingServer]: def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: - for item in items: - if "test_litellm_rust" not in item.path.parts: - continue - relative_path: Final = "/".join(item.path.parts[item.path.parts.index("test_litellm_rust") + 1 :]) - reason: Final = EXPECTED_FAILURE_REASONS.get(relative_path) - if reason is not None: - item.add_marker(pytest.mark.xfail(reason=reason, strict=False)) - if not _parse_env_bool(os.environ.get("LITELLM_RUST")): skip: Final = pytest.mark.skip(reason="requires LITELLM_RUST=1 and a compiled Rust extension") for item in items: diff --git a/tests/test_litellm_rust/ocr/test_callbacks.py b/tests/test_litellm_rust/ocr/test_callbacks.py index b08446412c0..1cfd04b1bff 100644 --- a/tests/test_litellm_rust/ocr/test_callbacks.py +++ b/tests/test_litellm_rust/ocr/test_callbacks.py @@ -41,7 +41,7 @@ def test_native_ocr_pre_call_callback_receives_transformed_provider_request(ocr_ observations: Final = [] class Observe(CustomLogger): - def log_pre_api_call(self, model, _messages, kwargs): + def log_pre_api_call(self, model, messages, kwargs): observations.append((model, copy.deepcopy(kwargs["additional_args"]))) call_native_ocr_with_callbacks(ocr_server, [Observe()], pages=[0]) @@ -64,13 +64,13 @@ def test_native_ocr_pre_call_body_edit_reaches_next_callback_and_provider( observed: Final = [] class Edit(CustomLogger): - def log_pre_api_call(self, model, _messages, kwargs): + def log_pre_api_call(self, model, messages, kwargs): request_body(kwargs)["include_image_base64"] = True if raise_after_edit: raise RuntimeError("pre-call callback failed") class Observe(CustomLogger): - def log_pre_api_call(self, model, _messages, kwargs): + def log_pre_api_call(self, model, messages, kwargs): observed.append(copy.deepcopy(request_body(kwargs))) call_native_ocr_with_callbacks(ocr_server, [Edit(), Observe()], include_image_base64=False) @@ -83,11 +83,11 @@ def test_native_ocr_pre_call_header_edit_reaches_next_callback_and_provider(ocr_ observed: Final = [] class Edit(CustomLogger): - def log_pre_api_call(self, model, _messages, kwargs): + def log_pre_api_call(self, model, messages, kwargs): request_headers(kwargs)["x-audit-tag"] = "reviewed" class Observe(CustomLogger): - def log_pre_api_call(self, model, _messages, kwargs): + def log_pre_api_call(self, model, messages, kwargs): observed.append(dict(request_headers(kwargs))) call_native_ocr_with_callbacks(ocr_server, [Edit(), Observe()]) @@ -96,6 +96,29 @@ 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( @@ -107,12 +130,12 @@ async def test_native_ocr_pre_call_nested_document_edit_updates_caller_callback_ aliases: Final = [] class Retain(CustomLogger): - def log_pre_api_call(self, model, _messages, kwargs): + def log_pre_api_call(self, model, messages, kwargs): aliases.append(request_body(kwargs)["document"] is original) retained.append(request_body(kwargs)["document"]) class Edit(CustomLogger): - def log_pre_api_call(self, model, _messages, kwargs): + def log_pre_api_call(self, model, messages, kwargs): original["document_url"] = replacement_url arguments: Final = { @@ -143,7 +166,7 @@ def test_native_ocr_pre_call_document_replacement_does_not_mutate_original_docum retained: Final = [] class RetainAndReplace(CustomLogger): - def log_pre_api_call(self, model, _messages, kwargs): + def log_pre_api_call(self, model, messages, kwargs): body = request_body(kwargs) retained.append(body["document"]) body["document"] = replacement @@ -165,11 +188,11 @@ def test_native_ocr_pre_call_body_rebinding_is_visible_to_callbacks_but_not_prov observed: Final = [] class Rebind(CustomLogger): - def log_pre_api_call(self, model, _messages, kwargs): + def log_pre_api_call(self, model, messages, kwargs): kwargs["additional_args"]["complete_input_dict"] = {"replacement": True} class Observe(CustomLogger): - def log_pre_api_call(self, model, _messages, kwargs): + def log_pre_api_call(self, model, messages, kwargs): observed.append(request_body(kwargs)) call_native_ocr_with_callbacks(ocr_server, [Rebind(), Observe()]) @@ -182,11 +205,11 @@ def test_native_ocr_callback_retained_body_observes_later_callback_mutation(ocr_ queued: Final = [] class QueuePayload(CustomLogger): - def log_pre_api_call(self, model, _messages, kwargs): + def log_pre_api_call(self, model, messages, kwargs): queued.append(request_body(kwargs)) class Edit(CustomLogger): - def log_pre_api_call(self, model, _messages, kwargs): + def log_pre_api_call(self, model, messages, kwargs): request_body(kwargs)["queued-edit"] = True call_native_ocr_with_callbacks(ocr_server, [QueuePayload(), Edit()]) @@ -200,7 +223,7 @@ def test_native_ocr_success_callback_receives_state_added_by_pre_call_callback(o finished: Final = threading.Event() class Stash(CustomLogger): - def log_pre_api_call(self, model, _messages, kwargs): + def log_pre_api_call(self, model, messages, kwargs): kwargs["test-token"] = token def log_success_event(self, kwargs, response_obj, start_time, end_time): @@ -280,7 +303,7 @@ async def test_native_aocr_failure_callbacks_receive_state_added_by_pre_call_cal observed: Final = [] class TrackInFlightRequest(CustomLogger): - def log_pre_api_call(self, model, _messages, kwargs): + def log_pre_api_call(self, model, messages, kwargs): kwargs["request-token"] = token def log_failure_event(self, kwargs, response_obj, start_time, end_time): @@ -364,7 +387,7 @@ async def test_native_azure_ocr_resolves_token_before_pre_call_on_caller_context return "caller-token" class Edit(CustomLogger): - def log_pre_api_call(self, model, _messages, kwargs): + def log_pre_api_call(self, model, messages, kwargs): assert request_headers(kwargs)["Authorization"] == "Bearer caller-token" observations.append("pre_call") request_headers(kwargs)["Authorization"] = "Bearer edited" diff --git a/tests/test_litellm_rust/ocr/test_cohere.py b/tests/test_litellm_rust/ocr/test_cohere.py new file mode 100644 index 00000000000..2a35dc62bd1 --- /dev/null +++ b/tests/test_litellm_rust/ocr/test_cohere.py @@ -0,0 +1,141 @@ +from typing import Final + +import pytest + +import litellm +from tests.test_litellm_rust.support.recording_server import RecordingServer, ResponseSpec + +pytestmark = pytest.mark.requires_rust_extension +MODELS: Final = ("cohere/parse-v5.0", "azure_ai/Cohere-parse-v5.0") +IMAGE: Final = {"type": "image_url", "image_url": "data:image/png;base64,YWJj"} +BOX: Final = {"top_left_x": 0, "top_left_y": 0, "bottom_right_x": 32, "bottom_right_y": 32} +PAYLOAD: Final = { + "pages": [ + { + "index": 4, + "markdown": {"content": "receipt", "images": [{"id": "image", "bounding_box": BOX, "description": "scan"}]}, + }, + {"markdown": {"content": "page two"}}, + ], + "meta": {"billed_units": {"pages": 3}}, +} + + +@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: + recording_server.enqueue(ResponseSpec(body=PAYLOAD)) + response: Final = await litellm.ahealth_check( + model_params={"model": model, "api_key": "test-key", "api_base": recording_server.base_url}, mode="ocr" + ) + 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_dispatch.py b/tests/test_litellm_rust/ocr/test_dispatch.py index a6c76bc5d0e..7b4b9fab579 100644 --- a/tests/test_litellm_rust/ocr/test_dispatch.py +++ b/tests/test_litellm_rust/ocr/test_dispatch.py @@ -1,11 +1,9 @@ from typing import Final -from unittest.mock import Mock import pytest import litellm from litellm.llms.base_llm.ocr.transformation import OCRResponse -from litellm.ocr import main as ocr_main from tests.test_litellm_rust.support.recording_server import RecordingServer, ResponseSpec from tests.test_litellm_rust.support.requests import OCR_DOCUMENT, OCR_MODEL, OCR_RESPONSE @@ -18,18 +16,9 @@ def ocr_server(recording_server: RecordingServer) -> RecordingServer: return recording_server -@pytest.mark.parametrize("rust_enabled", [True, False], ids=["enabled", "disabled"]) -def test_public_ocr_dispatches_according_to_rust_setting( - ocr_server: RecordingServer, - monkeypatch: pytest.MonkeyPatch, - rust_enabled: bool, -) -> None: - rust_call: Final = Mock(wraps=ocr_main.rust_ocr_bridge.ocr) - python_call: Final = Mock(wraps=ocr_main.base_llm_http_handler.ocr) - monkeypatch.setattr(ocr_main.rust_ocr_bridge, "ocr", rust_call) - monkeypatch.setattr(ocr_main.base_llm_http_handler, "ocr", python_call) - litellm.rust(rust_enabled) - +@pytest.mark.parametrize("enabled", [False, True, None]) +def test_public_ocr_uses_native_route_independently_of_flag(ocr_server: RecordingServer, enabled: bool | None) -> None: + litellm.rust(enabled) response: Final = litellm.ocr( model=OCR_MODEL, document=OCR_DOCUMENT, @@ -39,6 +28,26 @@ def test_public_ocr_dispatches_according_to_rust_setting( assert isinstance(response, OCRResponse) assert response.pages[0].markdown == "native OCR response" - assert rust_call.call_count == int(rust_enabled) - assert python_call.call_count == int(not rust_enabled) + assert len(ocr_server.requests) == 1 + assert not ocr_server.requests[0].headers.get("user-agent", "").startswith("python-httpx") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("caching", [None, False, True]) +async def test_ocr_does_not_depend_on_chat_cache( + ocr_server: RecordingServer, monkeypatch: pytest.MonkeyPatch, asynchronous: bool, caching: bool | None +) -> None: + from litellm.caching.caching import Cache + + monkeypatch.setattr(litellm, "cache", Cache(type="local", supported_call_types=["completion", "acompletion"])) + arguments: Final = { + "model": OCR_MODEL, + "document": OCR_DOCUMENT, + "api_key": "test-key", + "api_base": ocr_server.base_url, + "caching": caching, + } + response: Final = await litellm.aocr(**arguments) if asynchronous else litellm.ocr(**arguments) + assert response.pages[0].markdown == "native OCR response" assert len(ocr_server.requests) == 1 diff --git a/tests/test_litellm_rust/ocr/test_lifecycle.py b/tests/test_litellm_rust/ocr/test_lifecycle.py new file mode 100644 index 00000000000..1acad5527d8 --- /dev/null +++ b/tests/test_litellm_rust/ocr/test_lifecycle.py @@ -0,0 +1,996 @@ +import asyncio +import datetime +import gc +import json +import sys +import threading +import weakref +from collections.abc import Coroutine +from contextvars import ContextVar +from typing import Final + +import pytest + +import litellm +from litellm._logging import trace_id_var +from litellm.integrations.custom_logger import CustomLogger +from litellm.litellm_core_utils.litellm_logging import Logging +from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing +from tests.test_litellm_rust.support.callback_recorder import RecordingLogger, drain_logging +from tests.test_litellm_rust.support.recording_server import RecordingServer, ResponseSpec +from tests.test_litellm_rust.support.requests import OCR_RESPONSE, call_aocr, call_ocr + +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) + return recording_server + + +@pytest.mark.asyncio +async def test_proxy_metadata_remains_python_owned(ocr_server: RecordingServer) -> None: + from litellm.proxy._types import UserAPIKeyAuth + + recorder: Final = RecordingLogger() + auth: Final = UserAPIKeyAuth(user_id="ocr-user") + response: Final = await call_aocr( + ocr_server, callbacks=[recorder], metadata={"user_api_key_auth": auth}, shared_session=object() + ) + events: Final = await recorder.wait_for_async("async_log_success_event") + assert response.pages[0].markdown == "native OCR response" + assert events[0].kwargs["litellm_params"]["metadata"]["user_api_key_auth"].user_id == "ocr-user" + assert "metadata" not in ocr_server.requests[0].body + + +@pytest.mark.asyncio +async def test_response_replacement_finalized_before_dispatch_in_caller_task(ocr_server: RecordingServer) -> None: + caller: Final = asyncio.current_task() + context: Final = ContextVar("lifecycle-test", default="before") + observations: Final = [] + recorder: Final = RecordingLogger() + + class Replace(CustomLogger): + async def async_pre_call_deployment_hook(self, kwargs, call_type): + context.set("pre") + observations.append(("pre", asyncio.current_task(), context.get())) + return {**kwargs, "pages": [2]} + + async def async_post_call_success_deployment_hook(self, request_data, response, call_type): + observations.append(("post", asyncio.current_task(), context.get())) + return response.model_copy(update={"model": "replaced"}) + + litellm.callbacks.append(Replace()) + response: Final = await call_aocr(ocr_server, callbacks=[recorder], litellm_call_id="native-final") + events: Final = await recorder.wait_for_async("async_log_success_event") + assert observations == [("pre", caller, "pre"), ("post", caller, "pre")] + assert context.get() == "pre" + assert ocr_server.requests[0].body["pages"] == [2] + assert response.model == "replaced" + assert events[0].response is response + assert response._hidden_params["litellm_call_id"] == "native-final" + 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( + ocr_server: RecordingServer, asynchronous: bool +) -> None: + failure: Final = RuntimeError("metadata failed") + seen: Final = [] + + class FailingMetadata(Logging): + def _response_cost_calculator(self, *args, **kwargs): + raise failure + + def success_handler(self, *args, **kwargs): + seen.append("success") + + def failure_handler(self, exception, *args, **kwargs): + seen.append(("sync", exception)) + + async def async_failure_handler(self, exception, *args, **kwargs): + seen.append(("async", exception)) + + async def invoke(): + logger: Final = FailingMetadata( + model="mistral-ocr-latest", + messages=[], + stream=False, + call_type="aocr" if asynchronous else "ocr", + start_time=datetime.datetime.now(), + litellm_call_id="metadata", + function_id="metadata", + ) + reference: Final = weakref.ref(logger) + with pytest.raises(RuntimeError) as caught: + await call_aocr(ocr_server, litellm_logging_obj=logger) if asynchronous else call_ocr( + ocr_server, litellm_logging_obj=logger + ) + assert caught.value is failure + failure.__traceback__ = None + return reference + + reference: Final = await invoke() + await drain_logging() + gc.collect() + assert seen == ([("sync", failure), ("async", failure)] if asynchronous else [("sync", failure)]) + assert reference() is None + assert len(ocr_server.requests) == 1 + + +@pytest.mark.asyncio +async def test_mapped_failure_identity_and_deployment_snapshot(ocr_server: RecordingServer) -> None: + ocr_server.enqueue(ResponseSpec(body={"message": "unavailable"}, status=500)) + recorder: Final = RecordingLogger() + snapshots: Final = [] + + class Observe(CustomLogger): + async def async_post_call_failure_deployment_hook(self, request_data, exception, call_type, **kwargs): + snapshots.append(exception) + exception.status_code = 418 + + litellm.callbacks.append(Observe()) + with pytest.raises(litellm.InternalServerError) as caught: + await call_aocr(ocr_server, callbacks=[recorder]) + failures: Final = tuple(event for event in recorder.events if "failure" in event.name) + assert [event.name for event in failures] == ["log_failure_event", "async_log_failure_event"] + assert all(event.kwargs["exception"] is caught.value for event in failures) + assert caught.value.status_code == 500 + assert snapshots[0] is not caught.value + assert snapshots[0].status_code == 418 + assert len(ocr_server.requests) == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("phase", ["pre", "http", "post"]) +async def test_cancellation_cleans_up_in_caller_task_without_terminal_dispatch( + ocr_server: RecordingServer, phase: str +) -> None: + entered: Final = asyncio.Event() + recorder: Final = RecordingLogger() + + class Pause(CustomLogger): + async def async_pre_call_deployment_hook(self, kwargs, call_type): + if phase == "pre": + 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)) + if phase == "pre": + ocr_server.expected_requests = 0 + restored: Final = [] + + async def invoke(): + trace_id_var.set("parent") + try: + await call_aocr(ocr_server, callbacks=[recorder], litellm_trace_id="native-call") + finally: + restored.append(trace_id_var.get()) + + task: Final = asyncio.create_task(invoke()) + if phase == "http": + await ocr_server.wait_for_requests(1) + else: + await asyncio.wait_for(entered.wait(), 5) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + await drain_logging() + assert restored == ["parent"] + assert not any("success" in name or "failure" in name for name in recorder.names) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("blocked", [False, True]) +async def test_deferred_logging_requires_release_and_runs_at_most_once( + ocr_server: RecordingServer, blocked: bool +) -> None: + recorder: Final = RecordingLogger() + logger: Final = Logging( + model="mistral-ocr-latest", + messages=[], + stream=False, + call_type="aocr", + start_time=datetime.datetime.now(), + litellm_call_id="deferred", + function_id="deferred", + dynamic_async_success_callbacks=[recorder], + ) + logger._defer_async_logging = True + response: Final = await call_aocr(ocr_server, litellm_logging_obj=logger) + await drain_logging() + assert "async_log_success_event" not in recorder.names + ProxyBaseLLMRequestProcessing._flush_deferred_async_logging(logger, blocked) + ProxyBaseLLMRequestProcessing._flush_deferred_async_logging(logger, blocked) + await drain_logging() + events: Final = tuple(event for event in recorder.events if event.name == "async_log_success_event") + assert len(events) == int(not blocked) + if events: + 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") + thread: Final = threading.current_thread() + finished: Final = threading.Event() + observations: Final = [] + + class Observe(CustomLogger): + def log_success_event(self, kwargs, response_obj, start_time, end_time): + observations.append((threading.current_thread(), context.get(), response_obj)) + finished.set() + + response: Final = call_ocr(ocr_server, callbacks=[Observe()]) + assert finished.wait(5) + assert observations[0][0] is not thread + assert observations[0][1] == "caller" + 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 + recorder: Final = RecordingLogger() + outcomes: Final = [] + + class Nested(CustomLogger): + async def async_pre_call_deployment_hook(self, kwargs, call_type): + if kwargs.get("litellm_call_id") == "outer": + outcomes.append(await call_aocr(ocr_server, callbacks=[recorder], litellm_call_id="inner")) + + litellm.callbacks.append(Nested()) + outcomes.append(await call_aocr(ocr_server, callbacks=[recorder], litellm_call_id="outer")) + events: Final = await recorder.wait_for_async("async_log_success_event", count=2) + assert [event.kwargs["litellm_call_id"] for event in events] == ["inner", "outer"] + assert events[0].response is outcomes[0] + assert events[1].response is outcomes[1] + assert len(ocr_server.requests) == 2 + + +def test_sync_pre_call_can_make_nested_native_request(ocr_server: RecordingServer) -> None: + ocr_server.expected_requests = 2 + observed: Final = [] + + class Nested(CustomLogger): + def log_pre_api_call(self, model, messages, kwargs): + if kwargs["litellm_call_id"] == "outer-sync": + observed.append(call_ocr(ocr_server, litellm_call_id="inner-sync")) + + response: Final = call_ocr(ocr_server, callbacks=[Nested()], litellm_call_id="outer-sync") + assert observed[0].pages[0].markdown == response.pages[0].markdown + 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.main import _public_request + from litellm.rust_bridge import _native + + ocr_server.expected_requests = 0 + effects: Final = [] + + class File: + def read(self): + effects.append("read") + return b"abc" + + def create(): + file: Final = File() + kwargs: Final = {"model": "mistral/mistral-ocr-latest", "document": {"type": "file", "file": file}} + coroutine: Final = _native._ocr_lifecycle(_public_request("aocr", (), kwargs), (), kwargs, True) + file.owner = coroutine + coroutine.close() + return weakref.ref(file) + + reference: Final = create() + gc.collect() + assert reference() is None + assert effects == [] + + +@pytest.mark.asyncio +async def test_file_read_happens_after_deployment_hook_in_caller_task(ocr_server: RecordingServer) -> None: + effects: Final = [] + caller: Final = asyncio.current_task() + + class File: + def read(self): + effects.append(("read", asyncio.current_task())) + return b"abc" + + class Deployment(CustomLogger): + async def async_pre_call_deployment_hook(self, kwargs, call_type): + await asyncio.sleep(0) + effects.append(("hook", asyncio.current_task())) + + litellm.callbacks.append(Deployment()) + await call_aocr(ocr_server, document={"type": "file", "file": File()}) + assert effects == [("hook", caller), ("read", caller)] + + +@pytest.mark.asyncio +async def test_failure_callbacks_continue_within_both_families(ocr_server: RecordingServer) -> None: + ocr_server.enqueue(ResponseSpec(body={"message": "failed"}, status=500)) + observed: Final = [] + + class Broken(CustomLogger): + def log_failure_event(self, kwargs, response_obj, start_time, end_time): + observed.append(("broken-sync", kwargs["exception"])) + raise RuntimeError("sync observer") + + async def async_log_failure_event(self, kwargs, response_obj, start_time, end_time): + observed.append(("broken-async", kwargs["exception"])) + raise RuntimeError("async observer") + + class Following(CustomLogger): + def log_failure_event(self, kwargs, response_obj, start_time, end_time): + observed.append(("following-sync", kwargs["exception"])) + + async def async_log_failure_event(self, kwargs, response_obj, start_time, end_time): + observed.append(("following-async", kwargs["exception"])) + + with pytest.raises(litellm.InternalServerError) as caught: + await call_aocr(ocr_server, callbacks=[Broken(), Following()]) + assert [name for name, _ in observed] == ["broken-sync", "following-sync", "broken-async", "following-async"] + assert all(error is caught.value for _, error in observed) + + +@pytest.mark.asyncio +async def test_cancelling_native_transport_closes_connection_before_return() -> None: + received: Final = asyncio.Event() + disconnected: Final = asyncio.Event() + + async def provider(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + headers: Final = await reader.readuntil(b"\r\n\r\n") + length: Final = next( + int(line.split(b":", 1)[1]) + for line in headers.split(b"\r\n") + if line.lower().startswith(b"content-length:") + ) + await reader.readexactly(length) + received.set() + assert await reader.read() == b"" + disconnected.set() + writer.close() + await writer.wait_closed() + + server: Final = await asyncio.start_server(provider, "127.0.0.1", 0) + async with server: + port: Final = server.sockets[0].getsockname()[1] + task: Final = asyncio.create_task( + litellm.aocr( + model="mistral/mistral-ocr-latest", + document={"type": "document_url", "document_url": "data:application/pdf;base64,YWJj"}, + api_key="test-key", + api_base=f"http://127.0.0.1:{port}", + ) + ) + await asyncio.wait_for(received.wait(), 5) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + await asyncio.wait_for(disconnected.wait(), 1) + + +@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": {"previous_models": ["earlier"]}} + 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" + 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: + from litellm import utils + from litellm.litellm_core_utils import litellm_logging, logging_worker + + class DispatchProbe: + deployments = 0 + submissions = 0 + enqueues = 0 + + def deployment(self, *args: object, **kwargs: object) -> None: + self.deployments += 1 + + def submit(self, *args: object, **kwargs: object) -> None: + self.submissions += 1 + + def ensure_initialized_and_enqueue(self, coroutine: Coroutine[object, object, object]) -> None: + self.enqueues += 1 + coroutine.close() + + probe: Final = DispatchProbe() + for name in ( + "async_pre_call_deployment_hook", + "async_post_call_success_deployment_hook", + "async_post_call_failure_deployment_hook", + ): + monkeypatch.setattr(utils, name, probe.deployment) + monkeypatch.setattr(litellm_logging, "executor", probe) + monkeypatch.setattr(logging_worker, "GLOBAL_LOGGING_WORKER", probe) + if failure: + ocr_server.enqueue(ResponseSpec(body={"message": "provider failed"}, status=500)) + trace_id_var.set("callback-free-parent") + 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) + else: + response: Final = ( + await call_aocr(ocr_server, **arguments) if asynchronous else call_ocr(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 + assert response._hidden_params["_response_ms"] > 0 + assert trace_id_var.get() == "callback-free-parent" + assert probe.deployments == probe.submissions == probe.enqueues == 0 + assert len(created_loggers) == 1 + logger: Final = created_loggers[0] + assert not hasattr(logger, "_native_pending_logging") + assert logger.model_call_details["first_api_call_start_time"] <= logger.model_call_details["end_time"] + assert "standard_logging_object" not in logger.model_call_details + assert ( + "original_response" not in logger.model_call_details or logger.model_call_details["original_response"] is None + ) + assert "complete_input_dict" not in logger.model_call_details.get("additional_args", {}) + assert logger.model_call_details["response_cost"] == (0 if failure else response._hidden_params["response_cost"]) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "registration", ["success_callback", "_async_success_callback", "failure_callback", "_async_failure_callback"] +) +async def test_terminal_registration_added_during_http_is_observed( + ocr_server: RecordingServer, registration: str +) -> None: + failure: Final = "failure" in registration + observer: Final = RecordingLogger() + ocr_server.enqueue( + ResponseSpec( + body={"message": "provider failed"} if failure else OCR_RESPONSE, status=500 if failure else 200, delay=0.1 + ) + ) + task: Final = asyncio.create_task( + asyncio.to_thread(call_ocr, ocr_server) if registration == "success_callback" else call_aocr(ocr_server) + ) + await ocr_server.wait_for_requests(1) + getattr(litellm, registration).append(observer) + if failure: + with pytest.raises(litellm.InternalServerError): + await task + else: + await task + event: Final = ("async_" if registration.startswith("_async") else "") + ( + "log_failure_event" if failure else "log_success_event" + ) + await observer.wait_for_async(event) + assert event in observer.names + + +@pytest.fixture +def created_loggers(monkeypatch: pytest.MonkeyPatch) -> list[Logging]: + from litellm import utils + + original_setup: Final = utils.function_setup + loggers: Final[list[Logging]] = [] + + def setup( + call_type: str, + rules: utils.Rules, + start: datetime.datetime, + *args: object, + is_async_call: bool = True, + **kwargs: object, + ) -> tuple[Logging, dict[str, object]]: + logger, prepared = original_setup(call_type, rules, start, *args, is_async_call=is_async_call, **kwargs) + assert isinstance(logger, Logging) + setattr(logger, "_defer_async_logging", True) + loggers.append(logger) + return logger, prepared + + monkeypatch.setattr(utils, "function_setup", setup) + return loggers + + +@pytest.mark.asyncio +@pytest.mark.parametrize("consumer", ["logger_fn", "raw_global", "request_debug"]) +async def test_explicit_logging_consumers_keep_request_and_response_payloads( + ocr_server: RecordingServer, monkeypatch: pytest.MonkeyPatch, created_loggers: list[Logging], consumer: str +) -> None: + snapshots: Final[list[dict[str, object]]] = [] + if consumer == "raw_global": + monkeypatch.setattr(litellm, "log_raw_request_response", True) + arguments: Final = { + "logger_fn": {"logger_fn": lambda details: snapshots.append(dict(details))}, + "raw_global": {}, + "request_debug": {"litellm_request_debug": True}, + }[consumer] + response: Final = await call_aocr(ocr_server, **arguments) + details: Final = created_loggers[0].model_call_details + assert details["additional_args"]["complete_input_dict"]["model"] == "mistral-ocr-latest" + assert json.loads(details["original_response"])["pages"][0]["markdown"] == response.pages[0].markdown + if consumer.startswith("raw_"): + 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 d241fe08fc8..4f4b39fa6c6 100644 --- a/tests/test_litellm_rust/ocr/test_requests.py +++ b/tests/test_litellm_rust/ocr/test_requests.py @@ -1,3 +1,4 @@ +from pathlib import Path from typing import Final import pytest @@ -5,13 +6,13 @@ import pytest import litellm from litellm.llms.base_llm.ocr.transformation import OCRResponse 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_DOCUMENT, OCR_RESPONSE, call_native_aocr, call_native_ocr, ) -from tests.test_litellm_rust.support.recording_server import RecordingServer, ResponseSpec pytestmark = pytest.mark.requires_rust_extension @@ -79,6 +80,22 @@ def test_native_ocr_prepares_file_document_like_python(ocr_server: RecordingServ } +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) @@ -149,7 +166,7 @@ def test_native_ocr_normalizes_provider_response_model_and_usage(ocr_server: Rec assert response.usage_info.pages_processed == 1 -def test_native_ocr_maps_provider_400_without_exposing_response_body(ocr_server: RecordingServer) -> None: +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)) with pytest.raises(litellm.BadRequestError) as caught: @@ -158,13 +175,23 @@ def test_native_ocr_maps_provider_400_without_exposing_response_body(ocr_server: assert caught.value.status_code == 400 assert caught.value.model == "mistral-ocr-latest" assert caught.value.llm_provider == "mistral" - assert "invalid OCR request" not in str(caught.value) + assert "invalid OCR request" in str(caught.value) -def test_native_ocr_raises_transport_error_when_request_exceeds_timeout(ocr_server: RecordingServer) -> None: +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(RuntimeError, match="OCR transport failed"): + with pytest.raises(litellm.Timeout): call_native_ocr(ocr_server, timeout=0.01) assert len(ocr_server.requests) == 1 @@ -301,13 +328,10 @@ async def test_native_azure_ocr_token_provider_failure_prevents_pre_call_callbac @pytest.mark.parametrize( "configuration", - [ - {"azure_ad_token": "oidc/assertion", "client_id": "client", "tenant_id": "tenant"}, - {"model": "azure_ai/doc-intelligence/prebuilt-read"}, - ], - ids=["oidc-assertion", "document-intelligence-model"], + [{"azure_ad_token": "oidc/assertion", "client_id": "client", "tenant_id": "tenant"}], + ids=["invalid-oidc-assertion"], ) -def test_native_azure_ocr_rejects_unsupported_configuration_before_token_or_callbacks( +def test_public_azure_ocr_maps_invalid_oidc_configuration_before_token_or_request( ocr_server: RecordingServer, isolated_azure_auth: None, configuration: dict[str, object], @@ -327,10 +351,10 @@ def test_native_azure_ocr_rejects_unsupported_configuration_before_token_or_call "callbacks": [recorder], **configuration, } - with pytest.raises(NotImplementedError): + with pytest.raises(litellm.APIConnectionError): call_native_ocr(ocr_server, **arguments) assert calls == [] - assert recorder.events == () + assert "log_pre_api_call" not in recorder.names assert ocr_server.requests == [] @@ -432,3 +456,170 @@ async def test_native_azure_ocr_rejects_coroutine_returned_by_sync_token_provide coroutine.close() assert calls == [] assert ocr_server.requests == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize( + "override, expected_key", + [ + ({}, "credential-key"), + ({"api_key": "explicit-key"}, "explicit-key"), + ({"api_key": None}, "environment-key"), + ], + ids=["inherit", "explicit", "explicit-none"], +) +async def test_native_ocr_inherits_named_credentials_without_overwriting_arguments( + ocr_server: RecordingServer, + monkeypatch: pytest.MonkeyPatch, + asynchronous: bool, + override: dict[str, object], + expected_key: str, +) -> None: + from litellm.models.credentials import CredentialItem + + pages: Final = [0] + opaque: Final = object() + monkeypatch.setenv("MISTRAL_API_KEY", "environment-key") + monkeypatch.setattr( + litellm, + "credential_list", + [ + CredentialItem(credential_name="other", credential_info={}, credential_values={"api_key": "wrong-key"}), + CredentialItem( + credential_name="ocr-test", + credential_info={}, + credential_values={ + "api_key": "credential-key", + "api_base": ocr_server.base_url, + "pages": pages, + "opaque": opaque, + }, + ), + CredentialItem(credential_name="ocr-test", credential_info={}, credential_values={"api_key": "later-key"}), + ], + ) + + class Observer(RecordingLogger): + def log_pre_api_call(self, model, messages, kwargs): + super().log_pre_api_call(model, messages, kwargs) + pages.append(2) + + arguments: Final = { + "model": "mistral/mistral-ocr-latest", + "document": OCR_DOCUMENT, + "litellm_credential_name": "ocr-test", + "callbacks": [Observer()], + **override, + } + response: Final = await litellm.aocr(**arguments) if asynchronous else litellm.ocr(**arguments) + assert response.pages[0].markdown == "native OCR response" + assert ocr_server.requests[0].headers["authorization"] == f"Bearer {expected_key}" + assert ocr_server.requests[0].body["pages"] == [0, 2] + + +@pytest.mark.parametrize("source", ["sdk", "proxy"]) +@pytest.mark.parametrize( + "filename,mime", [("scan.PNG", "image/png"), ("document.pdf", "application/pdf"), ("note.txt", "text/plain")] +) +def test_ocr_file_helpers_use_native_document_preparation(source: str, filename: str, mime: str) -> None: + from io import BytesIO + + from litellm.ocr.input import convert_file_document_to_url_document, get_mime_type + from litellm.proxy.ocr_endpoints.endpoints import _build_document_from_upload + + file: Final = BytesIO(b"abc") + file.name = filename + document: Final = ( + convert_file_document_to_url_document({"type": "file", "file": file}) + if source == "sdk" + else _build_document_from_upload(b"abc", filename, "application/octet-stream; charset=utf-8") + ) + field: Final = "image_url" if mime.startswith("image/") else "document_url" + assert get_mime_type(filename) == mime + assert document == {"type": field, field: f"data:{mime};base64,YWJj"} + + +@pytest.mark.parametrize("attribute", ["read", "name"]) +def test_native_file_preparation_preserves_property_errors(attribute: str) -> None: + from litellm.ocr.input import convert_file_document_to_url_document + + 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(LookupError) as caught: + convert_file_document_to_url_document({"type": "file", "file": File()}) + assert caught.value is failure + + +@pytest.mark.parametrize("kind", ["bytes", "path", "reader"]) +def test_native_file_preparation_rejects_oversized_input(kind: str, tmp_path: Path) -> None: + from litellm.ocr.input import FileDocument, convert_file_document_to_url_document, get_max_file_bytes + + limit: Final = get_max_file_bytes() + 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[FileDocument] = { + "type": "file", + "file": path if kind == "path" else Reader() if kind == "reader" else b"a" * (limit + 1), + } + with pytest.raises(ValueError, match="exceeds the size limit"): + convert_file_document_to_url_document(document) + + +@pytest.mark.parametrize("kind", ["str", "path", "reader"]) +def test_native_upload_binding_rejects_filesystem_inputs(kind: str, tmp_path: Path) -> None: + from io import BytesIO + from typing import cast # noqa: TID251 # deliberately invalid inputs exercise the native runtime boundary + + from litellm.ocr.input import convert_upload_to_url_document + + path: Final = tmp_path / "secret.pdf" + path.write_bytes(b"server secret") + source: Final = str(path) if kind == "str" else path if kind == "path" else BytesIO(b"abc") + with pytest.raises(TypeError): + convert_upload_to_url_document(cast(bytes, source), "document.pdf", None) + + +@pytest.mark.parametrize("extra_bytes", [0, 1]) +def test_native_upload_enforces_file_size_limit(extra_bytes: int) -> None: + import base64 + + from litellm.ocr.input import convert_upload_to_url_document, get_max_file_bytes + + content: Final = b"a" * (get_max_file_bytes() + extra_bytes) + if extra_bytes: + with pytest.raises(ValueError, match="exceeds the size limit"): + convert_upload_to_url_document(content, "scan.pdf", None) + return + document: Final = convert_upload_to_url_document(content, "scan.pdf", None) + assert document["type"] == "document_url" + assert base64.b64decode(document["document_url"].split(",", 1)[1]) == content + + +def test_native_file_preparation_preserves_reader_exception() -> None: + from litellm.ocr.input import convert_file_document_to_url_document + + failure: Final = RuntimeError("reader failed") + + class Reader: + def read(self) -> bytes: + raise failure + + with pytest.raises(RuntimeError) as caught: + convert_file_document_to_url_document({"type": "file", "file": Reader()}) + assert caught.value is failure diff --git a/tests/test_litellm_rust/support/callback_recorder.py b/tests/test_litellm_rust/support/callback_recorder.py index 6de011b1414..d3749ccc095 100644 --- a/tests/test_litellm_rust/support/callback_recorder.py +++ b/tests/test_litellm_rust/support/callback_recorder.py @@ -81,7 +81,7 @@ class RecordingLogger(CustomLogger): await asyncio.wait_for(GLOBAL_LOGGING_WORKER.flush(), timeout=timeout) return tuple(event for event in self.events if event.name == name) - def log_pre_api_call(self, model, _messages, kwargs): + def log_pre_api_call(self, model, messages, kwargs): self._record("log_pre_api_call", kwargs) def log_success_event(self, kwargs, response_obj, start_time, end_time): diff --git a/tests/test_litellm_rust/support/recording_server.py b/tests/test_litellm_rust/support/recording_server.py index 5a9b9497c6e..228ed2cc454 100644 --- a/tests/test_litellm_rust/support/recording_server.py +++ b/tests/test_litellm_rust/support/recording_server.py @@ -58,7 +58,9 @@ def recording_service() -> Iterator[RecordingServer]: def _handle(self) -> None: content_length: Final = int(self.headers.get("Content-Length", "0")) raw_body: Final = self.rfile.read(content_length) if content_length else b"" - body: Final = json.loads(raw_body) if raw_body else None + body: Final = ( + json.loads(raw_body) if raw_body and self.headers.get_content_type() == "application/json" else None + ) requests.append( RecordedRequest( method=self.command, @@ -84,6 +86,7 @@ def recording_service() -> Iterator[RecordingServer]: pass do_POST = _handle + do_GET = _handle def log_message(self, format: str, *args: object) -> None: pass diff --git a/tests/test_litellm_rust/support/requests.py b/tests/test_litellm_rust/support/requests.py index d681d752ffd..7114e42a59e 100644 --- a/tests/test_litellm_rust/support/requests.py +++ b/tests/test_litellm_rust/support/requests.py @@ -2,7 +2,6 @@ from typing import Final import litellm from litellm.llms.base_llm.ocr.transformation import OCRResponse -from litellm.rust_bridge import ocr as native_ocr from tests.test_litellm_rust.support.recording_server import RecordingServer OCR_DOCUMENT: Final = {"type": "document_url", "document_url": "data:application/pdf;base64,YWJj"} @@ -36,11 +35,11 @@ async def call_aocr(server: RecordingServer, **kwargs: object) -> OCRResponse: def call_native_ocr(server: RecordingServer, **kwargs: object) -> OCRResponse: - return native_ocr.ocr(ocr_arguments(server, **kwargs)) + return call_ocr(server, **kwargs) async def call_native_aocr(server: RecordingServer, **kwargs: object) -> OCRResponse: - return await native_ocr.aocr(ocr_arguments(server, **kwargs)) + return await call_aocr(server, **kwargs) def request_body(kwargs: dict[str, object]) -> dict[str, object]: diff --git a/tests/test_litellm_rust/test_ocr.py b/tests/test_litellm_rust/test_ocr.py index ad1c8c652bb..e0e06d685b8 100644 --- a/tests/test_litellm_rust/test_ocr.py +++ b/tests/test_litellm_rust/test_ocr.py @@ -2,6 +2,7 @@ import json import threading from collections.abc import Generator from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from io import BytesIO from typing import Final import pytest @@ -99,6 +100,38 @@ def test_native_ocr_with_compiled_rust_extension( } +@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, +): + 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}, + 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 "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 @@ -145,24 +178,20 @@ async def test_native_public_ocr_matches_python(model, asynchronous): server: Final = ThreadingHTTPServer(("127.0.0.1", 0), Handler) thread: Final = Thread(target=server.serve_forever, daemon=True) thread.start() - responses: Final = [] try: - for enabled in (False, True): - litellm.rust(enabled) - 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) - responses.append(response.model_dump()) - assert len(calls) == 2 - assert calls[0] == calls[1] - for key in ("model", "pages", "object"): - assert responses[0][key] == responses[1][key] + 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() @@ -195,7 +224,7 @@ def test_native_ocr_rejects_invalid_input_before_network(ocr_server, custom_prov from litellm.rust_bridge import _native server, requests = ocr_server - with pytest.raises(ValueError, match=r"invalid (OCR request field|provider)|invalid request"): + with pytest.raises(ValueError, match="Document URL is required"): _native.ocr( model="mistral-ocr-latest", custom_llm_provider=custom_provider, @@ -224,7 +253,7 @@ async def test_native_ocr_enforces_request_deadline_without_fallback(ocr_server, "num_retries": 0, } started = time.monotonic() - with pytest.raises(litellm.APIConnectionError): + with pytest.raises(litellm.Timeout): await asyncio.wait_for( litellm.aocr(**arguments) if asynchronous else asyncio.to_thread(litellm.ocr, **arguments), timeout=3, diff --git a/ui/litellm-dashboard/public/assets/logos/conduct.png b/ui/litellm-dashboard/public/assets/logos/conduct.png new file mode 100644 index 00000000000..e68b32df916 Binary files /dev/null and b/ui/litellm-dashboard/public/assets/logos/conduct.png differ diff --git a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_modal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_modal.tsx index 50f9c7cda3c..492a6b5c630 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_modal.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_modal.tsx @@ -82,7 +82,7 @@ const BudgetModal: React.FC = ({ isModalVisible, setIsModalVis control={form.control} name="tpm_limit" label="Max Tokens per minute" - description="Default is model limit." + description="Leave blank for no LiteLLM limit. Provider rate limits still apply." > {({ ref, value, onChange, ...field }) => ( = ({ isModalVisible, setIsModalVis control={form.control} name="rpm_limit" label="Max Requests per minute" - description="Default is model limit." + description="Leave blank for no LiteLLM limit. Provider rate limits still apply." > {({ ref, value, onChange, ...field }) => ( = ({ isModalVisible, setIs control={form.control} name="tpm_limit" label="Max Tokens per minute" - description="Default is model limit." + description="Leave blank for no LiteLLM limit. Provider rate limits still apply." > {({ ref, value, onChange, ...field }) => ( = ({ isModalVisible, setIs control={form.control} name="rpm_limit" label="Max Requests per minute" - description="Default is model limit." + description="Leave blank for no LiteLLM limit. Provider rate limits still apply." > {({ ref, value, onChange, ...field }) => ( = ({ field, embeddingModels, name={name} disabled={disabled} value={typeof value === "string" && value !== "" ? value : null} - onValueChange={(selected: string | null) => onChange(selected ?? "")} + onValueChange={onChange} > = ({ field, embeddingModels, onChange(model?.value ?? "")} + onValueChange={(model: EmbeddingModelOption | null) => onChange(model?.value ?? null)} itemToStringLabel={(model: EmbeddingModelOption) => model.label} isItemEqualToValue={(model: EmbeddingModelOption, other: EmbeddingModelOption) => model.value === other.value diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/cacheSettingsUtils.ts b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/cacheSettingsUtils.ts index 7b9454a37c3..8da54e3ee78 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/cacheSettingsUtils.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/cacheSettingsUtils.ts @@ -1,6 +1,6 @@ import { CACHE_FIELDS, CacheField, CacheSection, REDACTED_VALUE, RedisType } from "./cacheSettingsFields"; -export type CacheFormValue = string | number | boolean | undefined; +export type CacheFormValue = string | number | boolean | null | undefined; export type CacheFormValues = Record; export type CacheSavePayloadValue = string | number | boolean | unknown[]; export type CacheSavePayload = Record; @@ -38,6 +38,9 @@ const initialValueForField = (field: CacheField, raw: unknown): CacheFormValue = return typeof source === "string" ? source : JSON.stringify(source, null, 2); } + if ((field.type === "select" || field.type === "model-select") && !hasValue(source)) { + return null; + } if (source === undefined || source === null) { return ""; } @@ -77,7 +80,7 @@ const saveValueForField = (field: CacheField, raw: CacheFormValue): CacheSavePay } if (typeof raw !== "string") { - return raw === undefined ? undefined : String(raw); + return raw == null ? undefined : String(raw); } const trimmed = raw.trim(); return trimmed === "" ? undefined : trimmed; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/index.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/index.integration.test.tsx index 918d8947151..0d1da4dc8ce 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/index.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/index.integration.test.tsx @@ -1,7 +1,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, renderWithProviders, screen, waitFor } from "../../../../../../tests/test-utils"; import userEvent from "@testing-library/user-event"; import CacheSettings from "./index"; +import { fetchAvailableModels } from "@/components/llm_calls/fetch_models"; const { getCacheSettingsCall, testCacheConnectionCall, updateCacheSettingsCall } = vi.hoisted(() => ({ getCacheSettingsCall: vi.fn(), @@ -29,7 +30,7 @@ const LOADED_WITH_ADVANCED = { }, }; -const renderSettings = () => render(); +const renderSettings = () => renderWithProviders(); const save = async (user: ReturnType) => user.click(screen.getByRole("button", { name: /save changes/i })); @@ -174,4 +175,42 @@ describe("CacheSettings advanced settings round-trip", () => { await waitFor(() => expect(updateCacheSettingsCall).toHaveBeenCalledTimes(1)); expect(updateCacheSettingsCall.mock.calls[0][1]).not.toHaveProperty("ttl"); }); + + it("should omit a cleared cache model from save and test while retaining other settings", async () => { + vi.mocked(fetchAvailableModels).mockResolvedValue([ + { model_group: "synthetic-embedding", mode: "embedding" }, + ] as Awaited>); + getCacheSettingsCall.mockResolvedValue({ + current_values: { + redis_type: "semantic", + host: "localhost", + redis_semantic_cache_embedding_model: "synthetic-embedding", + password: "***REDACTED***", + ttl: 0, + ssl: false, + namespace: "synthetic-cache", + }, + }); + const user = userEvent.setup(); + renderSettings(); + await screen.findByRole("combobox", { name: "Embedding Model" }); + await user.click(screen.getByRole("button", { name: "Clear" })); + const expected = { + type: "redis", + host: "localhost", + port: "6379", + similarity_threshold: 0.8, + semantic_cache_scope: "key", + ssl: false, + ssl_check_hostname: false, + ttl: 0, + namespace: "synthetic-cache", + }; + await user.click(screen.getByRole("button", { name: "Test Connection" })); + await waitFor(() => expect(testCacheConnectionCall).toHaveBeenCalledWith("sk-test", expected)); + await save(user); + await waitFor(() => + expect(updateCacheSettingsCall).toHaveBeenCalledWith("sk-test", { ...expected, type: "redis-semantic" }), + ); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_configs.ts b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_configs.ts index 7785a8e44ab..d0afc896260 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_configs.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_configs.ts @@ -318,4 +318,10 @@ export const GUARDRAIL_PRESETS: Record = { mode: "pre_call", defaultOn: false, }, + conduct: { + provider: "Conduct", + guardrailNameSuggestion: "Conduct Guard", + mode: "pre_call", + defaultOn: false, + }, }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_data.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_data.test.ts index 1e486639840..9a9ab3a61d7 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_data.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_data.test.ts @@ -28,6 +28,7 @@ const EXPECTED_PARTNER_LOGO_FILES: Record = { repelloai: "repelloai.png", straiker: "straiker.svg", alice: "alice.svg", + conduct: "conduct.png", }; describe("guardrail_garden_data logos", () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_data.ts b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_data.ts index 931b3a111d8..165bd8f9967 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_data.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_data.ts @@ -474,6 +474,16 @@ export const PARTNER_GUARDRAIL_CARDS: GuardrailCardInfo[] = [ tags: ["Content Moderation", "Prompt Injection", "PII", "Policy"], providerKey: "Alice", }, + { + id: "conduct", + name: "Conduct Guard", + description: + "Conduct Guard evaluates prompts against workspace rules before the model call: prompt injection, PII, and custom policies, with block, warning, and approval verdicts.", + category: "partner", + logo: guardrailLogoMap["Conduct Guard"], + tags: ["Security", "Prompt Injection", "PII", "Policy"], + providerKey: "Conduct", + }, ]; export const ALL_CARDS = [...LITELLM_CONTENT_FILTER_CARDS, ...PARTNER_GUARDRAIL_CARDS]; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info_helpers.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info_helpers.tsx index f686ff5644a..fb3cf8f309a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info_helpers.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info_helpers.tsx @@ -1,6 +1,7 @@ import aimSecurityLogo from "../../../../../public/assets/logos/aim_security.jpeg"; import aktoLogo from "../../../../../public/assets/logos/akto.svg"; import aliceLogo from "../../../../../public/assets/logos/alice.svg"; +import conductLogo from "../../../../../public/assets/logos/conduct.png"; import aporiaLogo from "../../../../../public/assets/logos/aporia.png"; import bedrockLogo from "../../../../../public/assets/logos/bedrock.svg"; import catoNetworksLogo from "../../../../../public/assets/logos/cato_networks.svg"; @@ -85,6 +86,7 @@ export const guardrail_provider_map: Record = { QostodianNexus: "qostodian_nexus", Repelloai: "repelloai", Alice: "alice", + Conduct: "conduct", }; // Function to populate provider map from API response - updates the original map @@ -208,6 +210,7 @@ export const guardrailLogoMap = { "RepelloAI Argus": repelloAiLogo.src, Straiker: straikerLogo.src, Alice: aliceLogo.src, + "Conduct Guard": conductLogo.src, } satisfies Record; export const getGuardrailLogo = (displayName: string): string | undefined => diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/common/useResourceList.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/hooks/common/useResourceList.test.tsx index 3ca67b082ec..b0c478920f3 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/common/useResourceList.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/common/useResourceList.test.tsx @@ -152,6 +152,28 @@ describe("useResourceList", () => { await waitFor(() => expect(lastCall().page_size).toBe(25)); }); + it("reports loading while a new search request is still pending", async () => { + let resolveSecond: ((value: ResourceListPage) => void) | undefined; + const fetchPage = vi.fn((query: ResourceListQuery) => { + calls.push(query); + if (calls.length === 1) return Promise.resolve(page([{ id: "a" }], 3)); + return new Promise>((resolve) => { + resolveSecond = resolve; + }); + }); + const { result } = renderList({ fetchPage }); + await waitFor(() => expect(result.current.rows).toEqual([{ id: "a" }])); + expect(result.current.isLoading).toBe(false); + + act(() => result.current.onSearchChange("zzz")); + await waitFor(() => expect(lastCall().q).toBe("zzz")); + expect(result.current.isLoading).toBe(true); + + act(() => resolveSecond?.(page([], 0))); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.rows).toEqual([]); + }); + it("surfaces a failed page as an error instead of empty rows", async () => { const fetchPage = vi.fn(() => Promise.reject(new Error("boom"))); const { result } = renderList({ fetchPage }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/common/useResourceList.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/common/useResourceList.ts index 8a6376b2248..983e537bd00 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/common/useResourceList.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/common/useResourceList.ts @@ -86,7 +86,7 @@ export function useResourceList(options: UseResourceListOptions): Re enabled, placeholderData: (previous) => previous, }; - const { data, isLoading, isFetching, error, refetch: refetchQuery } = useQuery(queryOptions); + const { data, isLoading, isPlaceholderData, isFetching, error, refetch: refetchQuery } = useQuery(queryOptions); const toFirstPage = useCallback(() => setPagination((previous) => ({ ...previous, pageIndex: 0 })), []); @@ -123,7 +123,7 @@ export function useResourceList(options: UseResourceListOptions): Re return { rows, rowCount: data?.meta.total_count ?? 0, - isLoading, + isLoading: isLoading || isPlaceholderData, isFetching, error, refetch, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useUpdateProject.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useUpdateProject.ts index 8e6bad04a28..e6dec85128c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useUpdateProject.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useUpdateProject.ts @@ -10,7 +10,7 @@ export interface ProjectUpdateParams { description?: string; team_id?: string; models?: string[]; - max_budget?: number; + max_budget?: number | null; blocked?: boolean; guardrails?: string[]; metadata?: Record; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/ToolArgumentsForm.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/ToolArgumentsForm.tsx index 90d38642a5d..48bac2a4dd9 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/ToolArgumentsForm.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/ToolArgumentsForm.tsx @@ -79,13 +79,16 @@ const ToolArgumentControl: React.FC<{ return ( @@ -108,8 +111,8 @@ const ToolArgumentControl: React.FC<{ if (prop.type === "boolean") { return ( onChange(setting.field_name, newValue ?? "")} - > + persist(ANTHROPIC_PROMPT_CACHING_TTL, newValue ?? "")} + value={ttlSetting.field_value ?? null} + onValueChange={(newValue) => persist(ANTHROPIC_PROMPT_CACHING_TTL, newValue)} > @@ -209,9 +206,11 @@ const GeneralSettings: React.FC = ({ accessToken, user return; } - let fieldValue = generalSettings.find((setting) => setting.field_name === fieldName)?.field_value; + const setting = generalSettings.find((setting) => setting.field_name === fieldName); + const fieldValue = setting?.field_value; - if (fieldValue == null || fieldValue == undefined) { + if (fieldValue == null) { + if (setting?.field_type === "Select") handleResetField(fieldName); return; } try { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/CreateSearchTools.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/CreateSearchTools.integration.test.tsx index b7962321333..dac43885da4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/CreateSearchTools.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/CreateSearchTools.integration.test.tsx @@ -1,5 +1,4 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, renderWithProviders, screen, testQueryClient, waitFor } from "../../../../../tests/test-utils"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import * as networking from "@/components/networking"; @@ -23,20 +22,16 @@ const providers = [ { provider_name: "tavily", ui_friendly_name: "Tavily Search" }, ]; -const renderModal = () => { - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); - return render( - - - , +const renderModal = () => + renderWithProviders( + , ); -}; const pickProvider = async (user: ReturnType, label: string) => { await user.click(screen.getAllByRole("combobox")[0]); @@ -45,6 +40,7 @@ const pickProvider = async (user: ReturnType, label: str describe("CreateSearchTools submit payload", () => { beforeEach(() => { + testQueryClient.clear(); vi.clearAllMocks(); vi.mocked(networking.fetchAvailableSearchProviders).mockResolvedValue({ providers }); vi.mocked(networking.createSearchTool).mockResolvedValue({ search_tool_id: "st-1" }); @@ -145,4 +141,24 @@ describe("CreateSearchTools submit payload", () => { ).toBeInTheDocument(); expect(networking.createSearchTool).not.toHaveBeenCalled(); }); + + it("should block creation after clearing the required provider and accept a restored choice", async () => { + const user = userEvent.setup(); + renderModal(); + fireEvent.change(await screen.findByLabelText(/Search Tool Name/), { target: { value: "synthetic-search" } }); + await pickProvider(user, "Perplexity AI"); + await user.click(screen.getByRole("button", { name: "Clear" })); + expect(networking.fetchAvailableSearchProviders).toHaveBeenCalledTimes(1); + await user.click(screen.getByRole("button", { name: "Add Search Tool" })); + expect(await screen.findByText("Please select a search provider")).toBeInTheDocument(); + expect(networking.createSearchTool).not.toHaveBeenCalled(); + await pickProvider(user, "Tavily Search"); + await user.click(screen.getByRole("button", { name: "Add Search Tool" })); + await waitFor(() => + expect(networking.createSearchTool).toHaveBeenCalledWith("test-token", { + search_tool_name: "synthetic-search", + litellm_params: { search_provider: "tavily" }, + }), + ); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/CreateSearchTools.tsx b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/CreateSearchTools.tsx index a724d979af5..eb778726f22 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/CreateSearchTools.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/CreateSearchTools.tsx @@ -65,7 +65,10 @@ const createSearchToolShape = { .string() .min(1, "Please enter a search tool name") .regex(/^[a-zA-Z0-9_-]+$/, "Name can only contain letters, numbers, hyphens, and underscores"), - search_provider: z.string().min(1, "Please select a search provider"), + search_provider: z + .string() + .nullable() + .pipe(z.string({ error: "Please select a search provider" }).min(1, "Please select a search provider")), api_key: z.string().optional(), description: z.string().optional(), }; @@ -74,7 +77,7 @@ const createSearchToolSchema = z.object(createSearchToolShape); type CreateSearchToolFormValues = z.infer; -const EMPTY_VALUES: CreateSearchToolFormValues = { search_tool_name: "", search_provider: "" }; +const EMPTY_VALUES: z.input = { search_tool_name: "", search_provider: null }; const labelWithHint = (label: string, hint: string): React.ReactNode => ( <> @@ -216,8 +219,8 @@ const CreateSearchTool: React.FC = ({ onChange(provider ?? "")} + value={value} + onValueChange={onChange} > = ({ placeholder="Select a search provider" className="h-10 w-full rounded-lg" disabled={isLoadingProviders} - showClear={value !== ""} + showClear={value != null && value !== ""} /> No matching search providers @@ -326,7 +329,7 @@ const CreateSearchTool: React.FC = ({ = ({ visible, onClose, accessT label={labelWithHint("Category (Optional)", "Select a category or enter a custom one")} > {({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => ( - onChange(category ?? "")} - > + No matching categories diff --git a/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/components/CreateTagModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/components/CreateTagModal.tsx index 6f6bdcb6fe4..adb5a167c35 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/components/CreateTagModal.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/components/CreateTagModal.tsx @@ -143,7 +143,11 @@ const CreateTagModal: React.FC = ({ visible, onCancel, onSu )} > {({ id, value, onChange }) => ( - + onChange(next ?? undefined)} + /> )} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/tag_info.tsx b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/tag_info.tsx index e8cb358c0cf..1648a99bb0e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/tag_info.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/tag_info.tsx @@ -28,7 +28,7 @@ const tagEditShape = { description: z.string().optional(), models: z.array(z.string()).optional(), max_budget: z.union([z.string(), z.number()]).optional(), - budget_duration: z.string().optional(), + budget_duration: z.string().nullish(), }; const tagEditSchema = z.object(tagEditShape); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx index 273e478528e..6c15b3c418d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx @@ -42,6 +42,7 @@ import { valueFormatterSpend } from "@/components/UsagePage/utils/value_formatte import EndpointUsage from "../EndpointUsage/EndpointUsage"; import ModelViewToggle, { ModelViewType } from "../ModelViewToggle"; import TopKeyView from "@/components/UsagePage/components/EntityUsage/TopKeyView"; +import KeyActivityPanel from "@/components/UsagePage/components/KeyActivityPanel"; import TopModelView from "./TopModelView"; import TeamUserSpendCard from "./TeamUserSpendCard"; @@ -654,7 +655,7 @@ const EntityUsage: React.FC = ({ { key: "keys", label: "Key Activity", - content: , + content: , }, { key: "endpoints", label: "Endpoint Activity", content: }, ]; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx index a92d1209567..de353948db9 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx @@ -30,6 +30,7 @@ import { ActivityMetrics, processActivityData } from "@/components/activity_metr import CloudZeroExportModal from "@/components/cloudzero_export_modal"; import UserDropdown from "@/components/common_components/UserDropdown"; import EntityUsageExportModal from "@/components/EntityUsageExport"; +import KeyActivityPanel from "@/components/UsagePage/components/KeyActivityPanel"; import { Team } from "@/components/key_team_helpers/key_list"; import { gatewayDailyActivityCall, @@ -886,7 +887,7 @@ const UsagePage: React.FC = ({ teams, organizations }) => { - + diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.test.tsx index 6b0423067fd..10a13c1dbd7 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.test.tsx @@ -344,5 +344,16 @@ describe("ViewUserDashboard", () => { expect(latest[4]).toBeNull(); expect(latest[2]).toBe(1); }); + + it("replaces the previous rows with the loading state while the search request is pending", async () => { + renderDashboard(); + expect(await screen.findByText("test@example.com")).toBeInTheDocument(); + + userListCall.mockReturnValue(new Promise(() => undefined)); + fireEvent.change(screen.getByPlaceholderText("Search by email or ID…"), { target: { value: "zzznomatch" } }); + + expect(await screen.findByText("Loading users…")).toBeInTheDocument(); + expect(screen.queryByText("test@example.com")).not.toBeInTheDocument(); + }); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.tsx index 1ed23e7f523..20ce22b6444 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.tsx @@ -295,7 +295,7 @@ const ViewUserDashboard: React.FC = ({ { expect(await openEditor(user)).toHaveValue(42); }); + + it("should keep Unlimited selected after saving and reopening the user", async () => { + const user = setup(); + render(); + + await openEditor(user); + await user.click(screen.getByRole("checkbox", { name: "Unlimited Budget" })); + await user.click(screen.getByRole("button", { name: /save changes/i })); + + await waitFor(() => expect(mockUserUpdateUserCall).toHaveBeenCalled()); + expect(mockUserUpdateUserCall.mock.calls[0][1]).toMatchObject({ max_budget: null }); + await openEditor(user); + expect(screen.getByRole("checkbox", { name: "Unlimited Budget" })).toBeChecked(); + }); + + it("should keep a cleared reset period after saving and reopening the user", async () => { + const user = setup(); + render(); + + await openEditor(user); + await user.click(screen.getByRole("combobox", { name: "Reset Budget" })); + await user.click(await screen.findByRole("option", { name: "n/a" })); + await user.click(screen.getByRole("button", { name: /save changes/i })); + + await waitFor(() => expect(mockUserUpdateUserCall).toHaveBeenCalled()); + expect(mockUserUpdateUserCall.mock.calls[0][1]).toMatchObject({ budget_duration: null }); + await openEditor(user); + expect(screen.getByRole("combobox", { name: "Reset Budget" })).toHaveTextContent("n/a"); + }); }); it("offers only the teams the user is not already a member of", async () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.tsx index eed39c8e585..e083e549552 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.tsx @@ -332,8 +332,9 @@ export default function UserInfoView({ user_email: formValues.user_email ?? userData.user_email, user_alias: formValues.user_alias ?? userData.user_alias, models: formValues.models ?? userData.models, - max_budget: formValues.max_budget ?? userData.max_budget, - budget_duration: formValues.budget_duration ?? userData.budget_duration, + max_budget: formValues.max_budget === undefined ? userData.max_budget : formValues.max_budget, + budget_duration: + formValues.budget_duration === undefined ? userData.budget_duration : formValues.budget_duration, metadata: formValues.metadata ?? userData.metadata, model_max_budget: formValues.model_max_budget ?? userData.model_max_budget, object_permission: mcpEntitlement diff --git a/ui/litellm-dashboard/src/components/Teams.tsx b/ui/litellm-dashboard/src/components/Teams.tsx index f0d21cc5350..dc531ea5dad 100644 --- a/ui/litellm-dashboard/src/components/Teams.tsx +++ b/ui/litellm-dashboard/src/components/Teams.tsx @@ -807,7 +807,7 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser showNeverResets placeholder={budgetDurationPlaceholder} value={value} - onChange={onChange} + onChange={(next) => onChange(next ?? undefined)} /> )} diff --git a/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.test.tsx b/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.test.tsx index 95958994775..9699ea2b7d1 100644 --- a/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.test.tsx +++ b/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.test.tsx @@ -138,6 +138,14 @@ it("shows a loading state on initial load and hides the data", () => { expect(screen.queryByText("Acme Team")).not.toBeInTheDocument(); }); +it("replaces the previous rows with the loading state while a new search is pending", () => { + mockUseTeamsTable.mockReturnValue(teamsResult([mockTeam], {}, { isPlaceholderData: true, isFetching: true })); + renderTable(); + + expect(screen.getByText("Loading teams...")).toBeInTheDocument(); + expect(screen.queryByText("Acme Team")).not.toBeInTheDocument(); +}); + describe("sort contract – only backend-sortable columns are sortable", () => { it("requests the default created_at descending sort on first render", () => { renderTable(); diff --git a/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.tsx b/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.tsx index bde1c7724c3..07f4a0bcbc4 100644 --- a/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.tsx +++ b/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.tsx @@ -83,7 +83,8 @@ export function TeamsTable({ userRole, userID, onSelectTeam, onEditTeam, onDelet const { data: teamsResponse, - isPending: isLoading, + isPending, + isPlaceholderData, isFetching, refetch, } = useTeamsTable(tablePagination.pageIndex + 1, tablePagination.pageSize, teamListOptions); @@ -161,7 +162,7 @@ export function TeamsTable({ userRole, userID, onSelectTeam, onEditTeam, onDelet onColumnFiltersChange={handleColumnFiltersChange} enableColumnResizing columnResizeMode="onChange" - isLoading={isLoading} + isLoading={isPending || isPlaceholderData} loadingMessage="Loading teams..." noDataMessage="No teams found" fillHeight diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.test.tsx new file mode 100644 index 00000000000..693ac20a360 --- /dev/null +++ b/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.test.tsx @@ -0,0 +1,71 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import type { ModelActivityData } from "../types"; +import KeyActivityPanel from "./KeyActivityPanel"; + +vi.mock("@/components/activity_metrics", () => ({ + ActivityMetrics: ({ modelMetrics }: { modelMetrics: Record }) => ( +
    + {Object.keys(modelMetrics).map((hash) => ( +
  • {hash}
  • + ))} +
+ ), +})); + +function activity(label: string, user_email: string | null, user_id: string | null): ModelActivityData { + return { + label, + key_metadata: { key_alias: label, team_id: "team-1", user_id, user_email }, + total_requests: 1, + total_successful_requests: 1, + total_failed_requests: 0, + total_cache_read_input_tokens: 0, + total_cache_creation_input_tokens: 0, + total_tokens: 10, + prompt_tokens: 5, + completion_tokens: 5, + total_spend: 0.01, + top_api_keys: [], + top_models: [], + daily_data: [], + }; +} + +const keyMetrics: Record = { + "hash-alice": activity("alice-key", "alice@example.com", "user-alice"), + "hash-bob": activity("bob-key", "bob@example.com", "user-bob"), +}; + +describe("KeyActivityPanel", () => { + it("renders every key and the full count before searching", () => { + render(); + expect(screen.getByTestId("rendered-keys")).toHaveTextContent("hash-alicehash-bob"); + expect(screen.getByText("Showing 2 of 2 keys")).toBeInTheDocument(); + }); + + it("narrows the rendered keys to those matching the user email", () => { + render(); + fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "bob@example.com" } }); + expect(screen.getByTestId("rendered-keys")).toHaveTextContent("hash-bob"); + expect(screen.getByTestId("rendered-keys")).not.toHaveTextContent("hash-alice"); + expect(screen.getByText("Showing 1 of 2 keys")).toBeInTheDocument(); + }); + + it("shows an empty state instead of zeroed metrics when nothing matches", () => { + render(); + fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "carol" } }); + expect(screen.queryByTestId("rendered-keys")).not.toBeInTheDocument(); + expect(screen.getByText('No keys match "carol" in this date range')).toBeInTheDocument(); + }); + + it("clears the search and restores every key", () => { + render(); + fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "user-alice" } }); + expect(screen.getByTestId("rendered-keys")).toHaveTextContent("hash-alice"); + fireEvent.click(screen.getByLabelText("Clear key search")); + expect(screen.getByLabelText("Search keys")).toHaveValue(""); + expect(screen.getByTestId("rendered-keys")).toHaveTextContent("hash-alicehash-bob"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.tsx new file mode 100644 index 00000000000..8287a04d0c7 --- /dev/null +++ b/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.tsx @@ -0,0 +1,58 @@ +import { Search, X } from "lucide-react"; +import React, { useMemo, useState } from "react"; + +import { ActivityMetrics } from "@/components/activity_metrics"; +import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group"; + +import { filterKeyActivity } from "../keyActivityFilter"; +import type { ModelActivityData } from "../types"; + +interface KeyActivityPanelProps { + keyMetrics: Record; + hidePromptCachingMetrics?: boolean; +} + +const KeyActivityPanel: React.FC = ({ keyMetrics, hidePromptCachingMetrics = false }) => { + const [query, setQuery] = useState(""); + const filtered = useMemo(() => filterKeyActivity(keyMetrics, query), [keyMetrics, query]); + const totalKeys = Object.keys(keyMetrics).length; + const shownKeys = Object.keys(filtered).length; + const isFiltering = query.trim() !== ""; + + return ( +
+
+ + + + + setQuery(e.target.value)} + /> + {isFiltering && ( + + setQuery("")}> + + + + )} + + + Showing {shownKeys.toLocaleString()} of {totalKeys.toLocaleString()} keys + +
+ {isFiltering && totalKeys > 0 && shownKeys === 0 ? ( +

+ No keys match "{query.trim()}" in this date range +

+ ) : ( + + )} +
+ ); +}; + +export default KeyActivityPanel; diff --git a/ui/litellm-dashboard/src/components/UsagePage/keyActivityFilter.test.ts b/ui/litellm-dashboard/src/components/UsagePage/keyActivityFilter.test.ts new file mode 100644 index 00000000000..ce181f6b0c6 --- /dev/null +++ b/ui/litellm-dashboard/src/components/UsagePage/keyActivityFilter.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; + +import { filterKeyActivity, keyActivityMatches } from "./keyActivityFilter"; +import type { KeyMetadata, ModelActivityData } from "./types"; + +function activity(label: string, key_metadata?: KeyMetadata): ModelActivityData { + return { + label, + key_metadata, + total_requests: 1, + total_successful_requests: 1, + total_failed_requests: 0, + total_cache_read_input_tokens: 0, + total_cache_creation_input_tokens: 0, + total_tokens: 10, + prompt_tokens: 5, + completion_tokens: 5, + total_spend: 0.01, + top_api_keys: [], + top_models: [], + daily_data: [], + }; +} + +const aliceMeta: KeyMetadata = { + key_alias: "alice-batch", + team_id: "team-research", + user_id: "user-alice-1234", + user_email: "alice@example.com", +}; +const bobMeta: KeyMetadata = { + key_alias: null, + team_id: "team-research", + user_id: "user-bob-5678", + user_email: "bob@example.com", +}; +const alice = activity("alice-batch (team: research)", aliceMeta); +const bob = activity("bob@example.com (team: research)", bobMeta); +const orphan = activity("key-hash-deadbeef", { key_alias: null, team_id: null }); + +const keyMetrics: Record = { + "hash-alice": alice, + "hash-bob": bob, + deadbeef: orphan, +}; + +describe("keyActivityMatches", () => { + it("matches every key on an empty or whitespace query", () => { + expect(keyActivityMatches("deadbeef", orphan, "")).toBe(true); + expect(keyActivityMatches("deadbeef", orphan, " ")).toBe(true); + }); + + it("matches key alias case-insensitively", () => { + expect(keyActivityMatches("hash-alice", alice, "ALICE-batch")).toBe(true); + expect(keyActivityMatches("hash-bob", bob, "alice-batch")).toBe(false); + }); + + it("matches user email", () => { + expect(keyActivityMatches("hash-bob", bob, "bob@example")).toBe(true); + expect(keyActivityMatches("hash-alice", alice, "bob@example")).toBe(false); + }); + + it("matches user id", () => { + expect(keyActivityMatches("hash-alice", alice, "user-alice-1234")).toBe(true); + expect(keyActivityMatches("hash-bob", bob, "user-alice-1234")).toBe(false); + }); + + it("matches the key hash when the key has no alias or user metadata", () => { + expect(keyActivityMatches("deadbeef", orphan, "dead")).toBe(true); + expect(keyActivityMatches("deadbeef", orphan, "alice")).toBe(false); + }); + + it("trims surrounding whitespace from the query", () => { + expect(keyActivityMatches("hash-alice", alice, " alice@example.com ")).toBe(true); + }); +}); + +describe("filterKeyActivity", () => { + it("returns the same object when the query is blank", () => { + expect(filterKeyActivity(keyMetrics, "")).toBe(keyMetrics); + }); + + it("keeps only the keys matching the query, preserving their hashes", () => { + expect(Object.keys(filterKeyActivity(keyMetrics, "example.com"))).toEqual(["hash-alice", "hash-bob"]); + expect(filterKeyActivity(keyMetrics, "user-bob")).toEqual({ "hash-bob": bob }); + }); + + it("returns an empty record when nothing matches", () => { + expect(filterKeyActivity(keyMetrics, "nobody")).toEqual({}); + }); +}); diff --git a/ui/litellm-dashboard/src/components/UsagePage/keyActivityFilter.ts b/ui/litellm-dashboard/src/components/UsagePage/keyActivityFilter.ts new file mode 100644 index 00000000000..1e9a654fb2e --- /dev/null +++ b/ui/litellm-dashboard/src/components/UsagePage/keyActivityFilter.ts @@ -0,0 +1,20 @@ +import type { ModelActivityData } from "./types"; + +export function keyActivityMatches(apiKey: string, data: ModelActivityData, query: string): boolean { + const needle = query.trim().toLowerCase(); + if (needle === "") return true; + const meta = data.key_metadata; + return [apiKey, data.label, meta?.key_alias, meta?.user_id, meta?.user_email].some( + (field) => field?.toLowerCase().includes(needle) ?? false, + ); +} + +export function filterKeyActivity( + keyMetrics: Record, + query: string, +): Record { + if (query.trim() === "") return keyMetrics; + return Object.fromEntries( + Object.entries(keyMetrics).filter(([apiKey, data]) => keyActivityMatches(apiKey, data, query)), + ); +} diff --git a/ui/litellm-dashboard/src/components/UsagePage/types.ts b/ui/litellm-dashboard/src/components/UsagePage/types.ts index a10e9e68c4d..fd4f1350020 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/types.ts +++ b/ui/litellm-dashboard/src/components/UsagePage/types.ts @@ -46,6 +46,7 @@ export interface KeyMetricWithMetadata { export interface KeyMetadata { key_alias: string | null; team_id: string | null; + user_id?: string | null; user_email?: string | null; tags?: { tag: string; usage: number }[]; } @@ -70,6 +71,7 @@ export interface TopModelData { export interface ModelActivityData { label: string; + key_metadata?: KeyMetadata; total_requests: number; total_successful_requests: number; total_failed_requests: number; diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx index c303dba697b..617b9209a41 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx @@ -289,6 +289,15 @@ it("should show a loading state on the initial load and hide the data", () => { expect(screen.queryByText("Test Key Alias")).not.toBeInTheDocument(); }); +it("replaces the previous rows with the loading state while a new search is pending", () => { + mockUseKeys.mockReturnValue(keysResult([mockKey], {}, { isPlaceholderData: true, isFetching: true })); + + renderWithProviders(); + + expect(screen.getByText("Loading keys...")).toBeInTheDocument(); + expect(screen.queryByText("Test Key Alias")).not.toBeInTheDocument(); +}); + it("should show 'No keys found' message when the key list is empty", () => { mockUseKeys.mockReturnValue(keysResult([])); diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx index d0367cb4d5b..907ee28bd05 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx @@ -128,7 +128,8 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) { const { data: keys, - isPending: isLoading, + isPending, + isPlaceholderData, isFetching, refetch, } = useKeys(tablePagination.pageIndex + 1, tablePagination.pageSize, keyListOptions); @@ -280,7 +281,7 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) { onColumnFiltersChange={handleColumnFiltersChange} enableColumnResizing columnResizeMode="onChange" - isLoading={isLoading} + isLoading={isPending || isPlaceholderData} loadingMessage="Loading keys..." noDataMessage="No keys found" fillHeight diff --git a/ui/litellm-dashboard/src/components/activity_metrics.test.tsx b/ui/litellm-dashboard/src/components/activity_metrics.test.tsx index b0fc8dc7866..914fe1872b6 100644 --- a/ui/litellm-dashboard/src/components/activity_metrics.test.tsx +++ b/ui/litellm-dashboard/src/components/activity_metrics.test.tsx @@ -655,6 +655,23 @@ describe("processActivityData", () => { expect(result["key1"].label).toBe("test-key-1 (team_id: team1)"); }); + it("retains the api key metadata so key activity can be searched by user", () => { + const metadata = { key_alias: "test-key-1", team_id: "team1", user_id: "user-1", user_email: "user1@example.com" }; + const withUser: { results: DailyData[] } = { + results: [ + createMockDailyData("2025-01-01", mockDailyActivity.results[0].metrics, { + ...EMPTY_BREAKDOWN, + api_keys: { key1: createMockKeyMetricWithMetadata(metadata, mockDailyActivity.results[0].metrics) }, + }), + ], + }; + + const result = processActivityData(withUser, "api_keys", MOCK_TEAMS); + + expect(result["key1"].key_metadata).toEqual(metadata); + expect(processActivityData(withUser, "models")["key1"]).toBeUndefined(); + }); + it("should process data for models key with data", () => { const dailyActivityWithModels: { results: DailyData[] } = { results: [ diff --git a/ui/litellm-dashboard/src/components/activity_metrics.tsx b/ui/litellm-dashboard/src/components/activity_metrics.tsx index f4348fb65ae..7c40a91be29 100644 --- a/ui/litellm-dashboard/src/components/activity_metrics.tsx +++ b/ui/litellm-dashboard/src/components/activity_metrics.tsx @@ -461,6 +461,7 @@ export const processActivityData = ( : key === "entities" ? (modelData as any).metadata?.agent_name || (modelData as any).metadata?.team_alias || model : model, + ...(key === "api_keys" ? { key_metadata: (modelData as KeyMetricWithMetadata).metadata } : {}), total_requests: 0, total_successful_requests: 0, total_failed_requests: 0, diff --git a/ui/litellm-dashboard/src/components/add_model/CompressionControls.tsx b/ui/litellm-dashboard/src/components/add_model/CompressionControls.tsx index e67583febf3..2f28e755a1d 100644 --- a/ui/litellm-dashboard/src/components/add_model/CompressionControls.tsx +++ b/ui/litellm-dashboard/src/components/add_model/CompressionControls.tsx @@ -20,8 +20,7 @@ const NONE_OPTION: SearchSelectOption = { label: "None (no compression)", value: const CompressionControls: React.FC = ({ value, onChange }) => { const { routing, sameAsRouting, model } = value; - const onRoutingChange = (newRouting: string | undefined) => - onChange({ ...value, routing: newRouting, sameAsRouting: newRouting === undefined ? true : sameAsRouting }); + const onRoutingChange = (newRouting: string | undefined) => onChange({ ...value, routing: newRouting }); const onSameAsRoutingChange = (newSameAsRouting: boolean) => onChange({ ...value, sameAsRouting: newSameAsRouting }); const onModelChange = (newModel: string | undefined) => onChange({ ...value, model: newModel }); diff --git a/ui/litellm-dashboard/src/components/add_model/ModelChoiceCombobox.tsx b/ui/litellm-dashboard/src/components/add_model/ModelChoiceCombobox.tsx index 9021536f22a..e73c22189c6 100644 --- a/ui/litellm-dashboard/src/components/add_model/ModelChoiceCombobox.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ModelChoiceCombobox.tsx @@ -17,8 +17,8 @@ export interface ModelChoice { interface ModelChoiceComboboxProps { id: string; - value: string; - onChange: (value: string) => void; + value: string | null; + onChange: (value: string | null) => void; choices: ModelChoice[]; placeholder: string; ariaInvalid: true | undefined; @@ -40,7 +40,7 @@ const ModelChoiceCombobox: React.FC = ({ onChange(choice?.value ?? "")} + onValueChange={(choice: ModelChoice | null) => onChange(choice?.value ?? null)} itemToStringLabel={(choice: ModelChoice) => choice.label} isItemEqualToValue={(choice: ModelChoice, current: ModelChoice) => choice.value === current.value} > @@ -50,7 +50,7 @@ const ModelChoiceCombobox: React.FC = ({ aria-describedby={ariaDescribedBy} placeholder={placeholder} className="w-full" - showClear={value !== ""} + showClear={value != null && value !== ""} /> No models found diff --git a/ui/litellm-dashboard/src/components/add_model/buildAutoRouterCompression.test.ts b/ui/litellm-dashboard/src/components/add_model/buildAutoRouterCompression.test.ts index 20d6af50d18..a3bd882243e 100644 --- a/ui/litellm-dashboard/src/components/add_model/buildAutoRouterCompression.test.ts +++ b/ui/litellm-dashboard/src/components/add_model/buildAutoRouterCompression.test.ts @@ -1,10 +1,23 @@ import { buildAutoRouterCompressionParams, + buildAutoRouterCompressionPatch, DEFAULT_AUTO_ROUTER_COMPRESSION, hydrateAutoRouterCompression, NO_COMPRESSION, } from "./buildAutoRouterCompression"; +describe("buildAutoRouterCompressionPatch", () => { + it.each([ + {}, + { auto_router_routing_compression: "routing-compressor" }, + { auto_router_model_compression: "model-compressor" }, + { auto_router_routing_compression: "none", auto_router_model_compression: "none" }, + { auto_router_routing_compression: "routing-compressor", auto_router_model_compression: "model-compressor" }, + ])("should preserve the exact stored fields on an untouched save: %j", (stored) => { + expect(buildAutoRouterCompressionPatch(hydrateAutoRouterCompression(stored), stored)).toEqual({}); + }); +}); + describe("buildAutoRouterCompressionParams", () => { it("omits both keys when routing was never configured", () => { expect(buildAutoRouterCompressionParams(DEFAULT_AUTO_ROUTER_COMPRESSION)).toEqual({}); diff --git a/ui/litellm-dashboard/src/components/add_model/buildAutoRouterCompression.ts b/ui/litellm-dashboard/src/components/add_model/buildAutoRouterCompression.ts index 6f401a12865..c3503f78afb 100644 --- a/ui/litellm-dashboard/src/components/add_model/buildAutoRouterCompression.ts +++ b/ui/litellm-dashboard/src/components/add_model/buildAutoRouterCompression.ts @@ -30,6 +30,8 @@ export interface AutoRouterCompressionLitellmParams { auto_router_model_compression?: string; } +type AutoRouterCompressionPatch = Partial>; + export const DEFAULT_AUTO_ROUTER_COMPRESSION: AutoRouterCompressionState = { routing: undefined, sameAsRouting: true, @@ -67,3 +69,18 @@ export const hydrateAutoRouterCompression = (litellmParams: { const sameAsRouting = model === routing; return { routing, sameAsRouting, model: sameAsRouting ? undefined : model }; }; + +export const buildAutoRouterCompressionPatch = ( + state: AutoRouterCompressionState, + stored: AutoRouterCompressionPatch, +): AutoRouterCompressionPatch => { + const initial = hydrateAutoRouterCompression(stored); + const modelUnchanged = state.sameAsRouting || state.model === initial.model; + if (state.routing === initial.routing && state.sameAsRouting === initial.sameAsRouting && modelUnchanged) { + return {}; + } + if (state.routing === undefined) { + return { auto_router_routing_compression: null, auto_router_model_compression: null }; + } + return buildAutoRouterCompressionParams(state); +}; diff --git a/ui/litellm-dashboard/src/components/common_components/budget_duration_dropdown.tsx b/ui/litellm-dashboard/src/components/common_components/budget_duration_dropdown.tsx index 3c38907c597..40ee857634c 100644 --- a/ui/litellm-dashboard/src/components/common_components/budget_duration_dropdown.tsx +++ b/ui/litellm-dashboard/src/components/common_components/budget_duration_dropdown.tsx @@ -14,7 +14,7 @@ const DURATION_LABELS: Record = { interface BudgetDurationDropdownProps { id?: string; value?: string | null; - onChange?: (value: string | undefined) => void; + onChange?: (value: string | null) => void; className?: string; style?: React.CSSProperties; placeholder?: string; @@ -31,11 +31,7 @@ const BudgetDurationDropdown: React.FC = ({ showNeverResets = false, }) => { return ( - diff --git a/ui/litellm-dashboard/src/components/edit_auto_router/editAutoRouterFormSchema.ts b/ui/litellm-dashboard/src/components/edit_auto_router/editAutoRouterFormSchema.ts new file mode 100644 index 00000000000..af84a0897c6 --- /dev/null +++ b/ui/litellm-dashboard/src/components/edit_auto_router/editAutoRouterFormSchema.ts @@ -0,0 +1,42 @@ +import { z } from "zod/v4"; + +const sharedShape = { + auto_router_name: z.string().min(1, "Auto router name is required"), + model_access_group: z.array(z.string()), +}; + +const complexityRouterShape = { + ...sharedShape, + auto_router_default_model: z + .string() + .nullable() + .transform((value) => value ?? ""), + auto_router_embedding_model: z + .string() + .nullable() + .transform((value) => value ?? ""), +}; + +const semanticRouterShape = { + ...sharedShape, + auto_router_default_model: z + .string() + .nullable() + .pipe(z.string({ error: "Default model is required" }).min(1, "Default model is required")), + auto_router_embedding_model: z + .string() + .nullable() + .pipe(z.string({ error: "Embedding model is required" }).min(1, "Embedding model is required")), +}; + +export const complexityRouterSchema = z.object(complexityRouterShape); +export const semanticRouterSchema = z.object(semanticRouterShape); + +export type EditAutoRouterFormValues = z.infer; + +export const EMPTY_FORM_VALUES: z.input = { + auto_router_name: "", + auto_router_default_model: null, + auto_router_embedding_model: null, + model_access_group: [], +}; diff --git a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.test.tsx b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.integration.test.tsx similarity index 94% rename from ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.test.tsx rename to ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.integration.test.tsx index 3367c810991..277a3825d5d 100644 --- a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.test.tsx +++ b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.integration.test.tsx @@ -1064,6 +1064,55 @@ describe("EditAutoRouterModal prompt compression", () => { />, ); + it("should clear both saved compression overrides when inheritance is selected", async () => { + const user = userEvent.setup(); + renderWithStoredCompression({ + auto_router_routing_compression: "routing-compressor", + auto_router_model_compression: "model-compressor", + }); + + await user.click(await screen.findByText("Advanced: Compression")); + await user.click(screen.getAllByRole("button", { name: "Clear", exact: true })[0]); + await user.click(screen.getByRole("button", { name: /save changes/i })); + + await waitFor(() => + expect(modelPatchUpdateCall).toHaveBeenCalledWith( + "token", + expect.objectContaining({ + litellm_params: expect.objectContaining({ + model: "auto_router/complexity_router", + auto_router_routing_compression: null, + auto_router_model_compression: null, + }), + }), + "auto-1", + ), + ); + }); + + it("should discard a cancelled clear and preserve compression when the saved choice is restored", async () => { + const user = userEvent.setup(); + const stored = { auto_router_routing_compression: "none", auto_router_model_compression: "model-compressor" }; + const view = renderWithStoredCompression(stored); + + await user.click(await screen.findByText("Advanced: Compression")); + await user.click(screen.getAllByRole("button", { name: "Clear", exact: true })[0]); + await user.click(screen.getByRole("button", { name: "Cancel", exact: true })); + expect(modelPatchUpdateCall).not.toHaveBeenCalled(); + view.unmount(); + + renderWithStoredCompression(stored); + await user.click(await screen.findByText("Advanced: Compression")); + expect(screen.getByRole("combobox", { name: "Routing decision compression" })).toHaveValue("None (no compression)"); + await user.click(screen.getAllByRole("button", { name: "Clear", exact: true })[0]); + await user.click(screen.getByRole("combobox", { name: "Routing decision compression" })); + await user.click(screen.getByRole("option", { name: "None (no compression)" })); + await user.click(screen.getByRole("button", { name: /save changes/i })); + + await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled()); + expect(savedLitellmParams()).toMatchObject(stored); + }); + it("leaves both compression keys out of an untouched save when none were stored", async () => { const user = userEvent.setup(); renderWithStoredCompression(); @@ -1075,18 +1124,24 @@ describe("EditAutoRouterModal prompt compression", () => { expect(savedLitellmParams()).not.toHaveProperty("auto_router_model_compression"); }); - it("preserves a stored same-as-routing compression through an untouched open-and-save", async () => { + it.each([ + { auto_router_routing_compression: "headroom-a", auto_router_model_compression: "headroom-a" }, + { auto_router_routing_compression: "routing-compressor" }, + { auto_router_model_compression: "model-compressor" }, + ])("should preserve the exact stored compression fields through an untouched save: %j", async (stored) => { const user = userEvent.setup(); - renderWithStoredCompression({ - auto_router_routing_compression: "headroom-a", - auto_router_model_compression: "headroom-a", - }); + renderWithStoredCompression(stored); await user.click(await screen.findByRole("button", { name: /save changes/i })); await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled()); - expect(savedLitellmParams()?.auto_router_routing_compression).toBe("headroom-a"); - expect(savedLitellmParams()?.auto_router_model_compression).toBe("headroom-a"); + expect( + Object.fromEntries( + Object.entries(savedLitellmParams()).filter( + ([key]) => key === "auto_router_routing_compression" || key === "auto_router_model_compression", + ), + ), + ).toEqual(stored); }); it("shows a stored different-compression choice as Use a different compression, not Same", async () => { diff --git a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx index 86f18ee9b12..c4166692f78 100644 --- a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx +++ b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx @@ -1,5 +1,10 @@ import React, { useEffect, useMemo, useState } from "react"; -import { z } from "zod/v4"; +import { + complexityRouterSchema, + semanticRouterSchema, + EMPTY_FORM_VALUES, + type EditAutoRouterFormValues, +} from "./editAutoRouterFormSchema"; import { toast } from "@/lib/toast"; import { CircleHelp } from "lucide-react"; import { FieldGroup } from "@/components/ui/field"; @@ -44,7 +49,7 @@ import { KeywordTierRule } from "../add_model/KeywordTierRules"; import { DEFAULT_MATCH_THRESHOLD } from "../add_model/SemanticKeywordMatching"; import { type AutoRouterCompressionState, - buildAutoRouterCompressionParams, + buildAutoRouterCompressionPatch, DEFAULT_AUTO_ROUTER_COMPRESSION, hydrateAutoRouterCompression, } from "../add_model/buildAutoRouterCompression"; @@ -400,35 +405,6 @@ export const buildUpdatedComplexityRouterConfig = ( }; }; -const sharedShape = { - auto_router_name: z.string().min(1, "Auto router name is required"), - model_access_group: z.array(z.string()), -}; - -const complexityRouterShape = { - ...sharedShape, - auto_router_default_model: z.string(), - auto_router_embedding_model: z.string(), -}; - -const semanticRouterShape = { - ...sharedShape, - auto_router_default_model: z.string().min(1, "Default model is required"), - auto_router_embedding_model: z.string().min(1, "Embedding model is required"), -}; - -const complexityRouterSchema = z.object(complexityRouterShape); -const semanticRouterSchema = z.object(semanticRouterShape); - -type EditAutoRouterFormValues = z.infer; - -const EMPTY_FORM_VALUES: EditAutoRouterFormValues = { - auto_router_name: "", - auto_router_default_model: "", - auto_router_embedding_model: "", - model_access_group: [], -}; - const labelWithHint = (label: string, hint: string): React.ReactNode => ( <> {label} @@ -587,8 +563,8 @@ const EditAutoRouterModal: React.FC = ({ // Set form values form.reset({ auto_router_name: modelData.model_name, - auto_router_default_model: modelData.litellm_params?.auto_router_default_model || "", - auto_router_embedding_model: modelData.litellm_params?.auto_router_embedding_model || "", + auto_router_default_model: modelData.litellm_params?.auto_router_default_model || null, + auto_router_embedding_model: modelData.litellm_params?.auto_router_embedding_model || null, model_access_group: modelData.model_info?.access_groups || [], }); } catch (error) { @@ -679,7 +655,7 @@ const EditAutoRouterModal: React.FC = ({ ...modelData.litellm_params, complexity_router_config: updatedConfig, complexity_router_default_model: defaultModel, - ...buildAutoRouterCompressionParams(autoRouterCompression), + ...buildAutoRouterCompressionPatch(autoRouterCompression, modelData.litellm_params ?? {}), }; const updatedModelInfo = { ...modelData.model_info, diff --git a/ui/litellm-dashboard/src/components/mcp_tools/MCPToolArgumentsForm.integration.test.tsx b/ui/litellm-dashboard/src/components/mcp_tools/MCPToolArgumentsForm.integration.test.tsx index b1174d1d37d..0da3628a76b 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/MCPToolArgumentsForm.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/MCPToolArgumentsForm.integration.test.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, renderWithProviders, screen } from "../../../tests/test-utils"; import userEvent from "@testing-library/user-event"; import { describe, it, expect } from "vitest"; import MCPToolArgumentsForm, { MCPToolArgumentsFormRef } from "./MCPToolArgumentsForm"; @@ -10,7 +10,7 @@ const toolWith = (schema: InputSchema | string): MCPTool => const renderForm = (schema: InputSchema | string) => { const ref = React.createRef(); - render(); + renderWithProviders(); return ref; }; @@ -101,7 +101,7 @@ describe("MCPToolArgumentsForm", () => { it("resets dotted defaults and positional values when the selected tool changes", async () => { const ref = React.createRef(); - const { rerender } = render( + const { rerender } = renderWithProviders( { await expect(submit(ref)).resolves.toEqual({}); }); }); + +it("should distinguish an unset enum from empty string and retain explicit false", async () => { + const user = userEvent.setup(); + const ref = renderForm({ + type: "object", + properties: { + mode: { type: "string", enum: ["", "fast"], default: "fast" }, + active: { type: "boolean", default: true }, + }, + }); + await user.click(screen.getByRole("combobox", { name: "mode" })); + await user.click(await screen.findByRole("option", { name: "Select mode" })); + await user.click(screen.getByRole("combobox", { name: "active" })); + await user.click(await screen.findByRole("option", { name: "False" })); + await expect(submit(ref)).resolves.toEqual({ active: false }); + await user.click(screen.getByRole("combobox", { name: "mode" })); + await user.click(await screen.findByRole("option", { name: "Empty string" })); + expect(screen.getByRole("combobox", { name: "mode" })).toHaveTextContent("Empty string"); + await expect(submit(ref)).resolves.toEqual({ mode: "", active: false }); +}); diff --git a/ui/litellm-dashboard/src/components/mcp_tools/MCPToolArgumentsForm.tsx b/ui/litellm-dashboard/src/components/mcp_tools/MCPToolArgumentsForm.tsx index ab3213d9b37..7d0b7fc1bd7 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/MCPToolArgumentsForm.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/MCPToolArgumentsForm.tsx @@ -23,6 +23,9 @@ const BOOLEAN_ITEMS = [ const isBlank = (value: unknown): boolean => value === undefined || value === null || value === ""; +const isUnsetArgument = (prop: InputSchemaProperty | undefined, value: unknown): boolean => + prop?.type === "string" && prop.enum ? value == null : isBlank(value); + const jsonErrorFor = (prop: InputSchemaProperty, value: unknown): string | null => { try { const parsed = typeof value === "string" ? JSON.parse(value) : value; @@ -45,10 +48,15 @@ const collectErrors = ( ): Record => { const entries = Object.entries(actualSchema.properties ?? {}).flatMap<[string, FieldError]>(([key, prop]) => { const value = values[key]; - const blank = isBlank(value); + const blank = isUnsetArgument(prop, value); if (actualSchema.required?.includes(key) && blank) { return [[key, { type: "required", message: requiredMessages[key] ?? `Please enter ${key}` }]]; } + if (prop.type === "string" && prop.enum) { + if (!blank && !prop.enum.includes(String(value))) { + return [[key, { type: "validate", message: `Please select a valid ${key}` }]]; + } + } if (prop.type !== "object" && prop.type !== "array") return []; if (blank) return []; const message = jsonErrorFor(prop, value); @@ -146,6 +154,7 @@ function buildDefaultValue(prop?: InputSchemaProperty, overrideDefault?: any): a } const getInitialValueForField = (prop: InputSchemaProperty): any => { + if (prop.type === "string" && prop.enum && prop.default === undefined) return null; const defaultValue = buildDefaultValue(prop); if (prop.type === "object" || prop.type === "array") { const fallback = prop.type === "array" ? [] : {}; @@ -164,7 +173,7 @@ function convertFormValues( Object.entries(values).forEach(([key, value]) => { const prop = schemaToUse.properties?.[key]; - if (prop && value !== null && value !== undefined && value !== "") { + if (prop && !isUnsetArgument(prop, value)) { switch (prop.type) { case "boolean": convertedValues[key] = value === "true" || value === true; @@ -202,7 +211,7 @@ function convertFormValues( default: convertedValues[key] = value; } - } else if (value !== null && value !== undefined && value !== "") { + } else if (!isUnsetArgument(prop, value)) { convertedValues[key] = value; } }); @@ -342,20 +351,22 @@ const MCPToolArgumentsForm = forwardRef { if (prop.type === "string" && prop.enum) { return ( - - + + {field.value === "" ? "Empty string" : undefined} + - {!required && Select {key}} + {!required && Select {key}} {prop.enum.map((v) => ( - {v} + {v === "" ? "Empty string" : v} ))} @@ -364,7 +375,11 @@ const MCPToolArgumentsForm = forwardRef + + {canDetach && ( + <> + {pending && ( +

+ The project will be removed when you save. Team, organization, and key limits will stay the same. +

+ )} + + + )} + + ); +} + +type ProjectKeyTeam = Pick & { + team_member_permissions?: string[] | null; +}; + +export function canDetachKeyProject( + team: ProjectKeyTeam | undefined, + organizations: Organization[] | undefined, + userID: string | null, + userRole: string | null, +): boolean { + if (isProxyAdminRole(userRole ?? "")) return true; + const member = team?.members_with_roles?.find((candidate) => candidate.user_id === userID); + if (member?.role === "admin") return true; + const canUpdateKey = member != null && team?.team_member_permissions?.includes("/key/update"); + const keyOrganizations = organizations?.filter((org) => org.organization_id === team?.organization_id); + return Boolean(canUpdateKey && isOrgAdminForAnyOrg(keyOrganizations, userID)); +} diff --git a/ui/litellm-dashboard/src/components/templates/keyEditFormValues.ts b/ui/litellm-dashboard/src/components/templates/keyEditFormValues.ts index fec8e749143..233b58b48ab 100644 --- a/ui/litellm-dashboard/src/components/templates/keyEditFormValues.ts +++ b/ui/litellm-dashboard/src/components/templates/keyEditFormValues.ts @@ -49,6 +49,7 @@ export interface KeyEditFormValues { skills?: string[]; organization_id?: string | null; team_id?: string | null; + project_id?: string | null; logging_settings?: unknown[]; metadata?: string; duration?: string | null; @@ -106,6 +107,7 @@ export const toKeyEditFormValues = (keyData: KeyResponse): KeyEditFormValues => skills: keyData.object_permission?.skills || [], organization_id: keyData.organization_id, team_id: keyData.team_id, + project_id: keyData.project_id, logging_settings: extractLoggingSettings(keyData.metadata), metadata: formatMetadataForDisplay(stripTagsFromMetadata(keyData.metadata)), duration: (keyData as { duration?: string }).duration ?? "", @@ -153,6 +155,7 @@ export const keyEditFormSchema = z.object({ skills: z.custom(), organization_id: z.custom(), team_id: z.custom(), + project_id: z.string().nullable().optional(), logging_settings: z.custom(), metadata: z.custom(), duration: z.custom(), diff --git a/ui/litellm-dashboard/src/components/templates/key_edit_view.integration.test.tsx b/ui/litellm-dashboard/src/components/templates/key_edit_view.integration.test.tsx index 7c2226f0369..cbe17b67865 100644 --- a/ui/litellm-dashboard/src/components/templates/key_edit_view.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.integration.test.tsx @@ -1,12 +1,13 @@ import { fireEvent, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { chooseSelectOption, renderWithProviders } from "../../../tests/test-utils"; +import { chooseSelectOption, renderWithProviders, testQueryClient } from "../../../tests/test-utils"; import { KeyResponse } from "../key_team_helpers/key_list"; import { MODEL_MAX_BUDGET_PREMIUM_HINT } from "../key_team_helpers/ModelMaxBudgetEditor"; import { getPassThroughEndpointsCall, getPoliciesList, + getUiSettings, getPromptsList, modelAvailableCall, vectorStoreListCall, @@ -22,6 +23,7 @@ vi.mock("../networking", async () => { const actual = await vi.importActual("../networking"); return { ...actual, + getUiSettings: vi.fn().mockResolvedValue({ values: { enable_projects_ui: false } }), getPromptsList: vi.fn().mockResolvedValue({ prompts: [{ prompt_id: "prompt-1" }, { prompt_id: "prompt-2" }], }), @@ -88,7 +90,11 @@ vi.mock("../common_components/RouterSettingsAccordion", async () => { vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({ useOrganizations: vi.fn().mockReturnValue({ data: [ - { organization_id: "org-1", organization_alias: "Engineering" }, + { + organization_id: "org-1", + organization_alias: "Engineering", + members: [{ user_id: "user-orbit", user_role: "org_admin" }], + }, { organization_id: "org-2", organization_alias: "Sales" }, ], isLoading: false, @@ -366,6 +372,8 @@ describe("KeyEditView", () => { beforeEach(() => { vi.clearAllMocks(); can.mockReturnValue(true); + vi.mocked(getUiSettings).mockResolvedValue({ values: { enable_projects_ui: false } }); + testQueryClient.removeQueries({ queryKey: ["uiSettings"] }); }); describe("policy and prompt fields", () => { @@ -1512,7 +1520,61 @@ describe("KeyEditView", () => { }); }); - it("keeps project key relationships locked and omits unsupported project updates", async () => { + it("should save an explicit project detach while keeping parents locked until the saved key changes", async () => { + vi.mocked(getUiSettings).mockResolvedValue({ values: { enable_projects_ui: true } }); + const onSubmit = vi.fn().mockResolvedValue(undefined); + const onCancel = vi.fn(); + const key = { ...MOCK_KEY_DATA, organization_id: "org-1", team_id: "group-maple", project_id: "project-orbit" }; + const team = { + team_id: "group-maple", + organization_id: "org-1", + members_with_roles: [] as { user_id: string; role: string }[], + team_member_permissions: [] as string[], + }; + const renderEditor = (keyData: KeyResponse = key, role = "Admin", editorTeam = team) => ( + + ); + const view = renderWithProviders(renderEditor()); + await userEvent.click(await screen.findByRole("button", { name: "Detach from project" })); + expect(screen.getByRole("combobox", { name: "Organization" })).toBeDisabled(); + expect(screen.getByRole("combobox", { name: "Team ID" })).toBeDisabled(); + await userEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(onCancel).toHaveBeenCalledOnce(); + expect(onSubmit).not.toHaveBeenCalled(); + view.rerender(renderEditor({ ...key })); + await userEvent.click(await screen.findByRole("button", { name: "Detach from project" })); + await userEvent.click(screen.getByRole("button", { name: /save changes/i })); + const expectedDetach = { project_id: null, organization_id: "org-1", team_id: "group-maple", models: key.models }; + await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining(expectedDetach))); + expect(screen.getByRole("combobox", { name: "Team ID" })).toBeDisabled(); + view.rerender(renderEditor({ ...key, project_id: null })); + expect(screen.getByRole("combobox", { name: "Team ID" })).toBeEnabled(); + expect(screen.queryByRole("button", { name: "Detach from project" })).not.toBeInTheDocument(); + view.rerender(renderEditor(key, "Internal User")); + expect(screen.queryByRole("button", { name: "Detach from project" })).not.toBeInTheDocument(); + view.rerender(renderEditor(key, "Org Admin")); + expect(screen.queryByRole("button", { name: "Detach from project" })).not.toBeInTheDocument(); + const memberTeam = { ...team, members_with_roles: [{ user_id: "user-orbit", role: "user" }] }; + view.rerender(renderEditor(key, "Org Admin", memberTeam)); + expect(screen.queryByRole("button", { name: "Detach from project" })).not.toBeInTheDocument(); + const permittedTeam = { ...memberTeam, team_member_permissions: ["/key/update"] }; + view.rerender(renderEditor(key, "Org Admin", permittedTeam)); + expect(await screen.findByRole("button", { name: "Detach from project" })).toBeInTheDocument(); + const adminTeam = { ...team, members_with_roles: [{ user_id: "user-orbit", role: "admin" }] }; + view.rerender(renderEditor(key, "Internal User", adminTeam)); + expect(await screen.findByRole("button", { name: "Detach from project" })).toBeInTheDocument(); + }); + + it("keeps project key relationships locked and omits project updates when the project UI is disabled", async () => { const onSubmit = vi.fn().mockResolvedValue(undefined); renderWithProviders( (null); const keyTypeFieldId = React.useId(); - const projectFieldId = React.useId(); const { data: organizations, isLoading: isOrganizationsLoading } = useOrganizations(); - const { data: projects } = useProjects(); const { data: uiSettingsData } = useUISettings(); const enableProjectsUI = Boolean(uiSettingsData?.values?.enable_projects_ui); const hasProject = Boolean(keyData.project_id); - const projectDisplay = (() => { - if (!keyData.project_id) return null; - const project = projects?.find((p) => p.project_id === keyData.project_id); - return project?.project_alias ? `${project.project_alias} (${keyData.project_id})` : keyData.project_id; - })(); + const detachProject = hasProject && form.watch("project_id") === null; + const canDetachProject = canDetachKeyProject(team, organizations, userID, userRole); const allowedRoutesValue = form.watch("allowed_routes"); const selectedModels = (form.watch("models") as string[] | undefined) ?? []; @@ -296,7 +291,12 @@ export function KeyEditView({ values.router_settings = routerSettings; } - await onSubmit(withNormalizedEstimates(values)); + await onSubmit( + withNormalizedEstimates({ + ...values, + ...(detachProject && enableProjectsUI && canDetachProject ? { project_id: null } : {}), + }), + ); } finally { setIsKeySaving(false); } @@ -813,10 +813,13 @@ export function KeyEditView({ {enableProjectsUI && hasProject && ( - - Project - - + form.setValue("project_id", detachProject ? keyData.project_id : null)} + /> )} diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 7ed8df6815d..c34975549e3 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -21,6 +21,26 @@ export interface paths { patch?: never; trace?: never; }; + "/.well-known/agent-skills/index.json": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Agent Skills Index + * @description Agent Skills v0.2.0 discovery index over every skill stored on this proxy. + */ + get: operations["agent_skills_index__well_known_agent_skills_index_json_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/.well-known/jwks.json": { parameters: { query?: never; @@ -310,6 +330,26 @@ export interface paths { patch?: never; trace?: never; }; + "/.well-known/skills/index.json": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Agent Skills Index + * @description Agent Skills v0.2.0 discovery index over every skill stored on this proxy. + */ + get: operations["agent_skills_index__well_known_skills_index_json_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/a2a/{agent_id}": { parameters: { query?: never; @@ -8089,6 +8129,7 @@ export interface paths { * - user_id: Optional[str] - User ID associated with key * - team_id: Optional[str] - Team ID associated with key * - agent_id: Optional[str] - The agent id associated with the key. + * - project_id: Optional[str] - Omit to retain the project, or send null to detach. A different project ID is rejected. * - organization_id: Optional[str] - The organization id of the key. * - budget_id: Optional[str] - The budget id associated with the key. Created by calling `/budget/new`. * - models: Optional[list] - Model_name's a user is allowed to call @@ -19981,6 +20022,26 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/skills/{skill_id}/archive": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Agent Skills Archive + * @description Stored skill upload, repacked so SKILL.md sits at the archive root. + */ + get: operations["agent_skills_archive_v1_skills__skill_id__archive_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/threads": { parameters: { query?: never; @@ -23137,6 +23198,32 @@ export interface components { /** Tags */ tags?: string[]; }; + /** AgentSkillsIndex */ + AgentSkillsIndex: { + /** + * $Schema + * @default https://schemas.agentskills.io/discovery/0.2.0/schema.json + */ + $schema: string; + /** Skills */ + skills: components["schemas"]["AgentSkillsIndexEntry"][]; + }; + /** AgentSkillsIndexEntry */ + AgentSkillsIndexEntry: { + /** Description */ + description: string; + /** Digest */ + digest: string; + /** Name */ + name: string; + /** + * Type + * @constant + */ + type: "archive"; + /** Url */ + url: string; + }; /** * AlertType * @description Enum for alert types and management event types @@ -28373,6 +28460,8 @@ export interface components { team_id?: string | null; /** User Email */ user_email?: string | null; + /** User Id */ + user_id?: string | null; }; /** * KeyMetricWithMetadata @@ -37991,6 +38080,11 @@ export interface components { } | null; /** Policies */ policies?: string[] | null; + /** + * Project Id + * @description Omit to retain the project, or send null to detach. Assigning a different project is not supported. + */ + project_id?: string | null; /** Prompts */ prompts?: string[] | null; /** Rotation Interval */ @@ -40090,6 +40184,26 @@ export interface operations { }; }; }; + agent_skills_index__well_known_agent_skills_index_json_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AgentSkillsIndex"]; + }; + }; + }; + }; jwks_json__well_known_jwks_json_get: { parameters: { query?: never; @@ -40418,6 +40532,26 @@ export interface operations { }; }; }; + agent_skills_index__well_known_skills_index_json_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AgentSkillsIndex"]; + }; + }; + }; + }; invoke_agent_a2a_a2a__agent_id__post: { parameters: { query?: never; @@ -65148,6 +65282,37 @@ export interface operations { }; }; }; + agent_skills_archive_v1_skills__skill_id__archive_get: { + parameters: { + query?: never; + header?: never; + path: { + skill_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/zip": string; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; create_threads_v1_threads_post: { parameters: { query?: never; diff --git a/uv.lock b/uv.lock index 88f558221e7..eb4cdef76f1 100644 --- a/uv.lock +++ b/uv.lock @@ -10,7 +10,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-09-08T03:56:24.358378Z" +exclude-newer = "2026-09-09T21:39:49.468411Z" exclude-newer-span = "P3D" [manifest] @@ -4390,6 +4390,7 @@ cli = [ { name = "pyyaml" }, { name = "requests" }, { name = "rich" }, + { name = "tomlkit" }, ] extra-proxy = [ { name = "a2a-sdk" }, @@ -4444,6 +4445,7 @@ proxy = [ { name = "rq" }, { name = "soundfile" }, { name = "starlette" }, + { name = "tomlkit" }, { name = "uvicorn" }, { name = "uvloop", marker = "sys_platform != 'win32'" }, { name = "websockets" }, @@ -4669,6 +4671,8 @@ requires-dist = [ { name = "starlette", marker = "extra == 'proxy'", specifier = ">=1.0.1,<2.0" }, { name = "tiktoken", specifier = ">=0.8.0,<1.0" }, { name = "tokenizers", specifier = ">=0.21.0,<1.0" }, + { name = "tomlkit", marker = "extra == 'cli'", specifier = ">=0.13.3,<1.0" }, + { name = "tomlkit", marker = "extra == 'proxy'", specifier = ">=0.13.3,<1.0" }, { name = "uvicorn", marker = "extra == 'proxy'", specifier = ">=0.33.0,<1.0" }, { name = "uvloop", marker = "sys_platform != 'win32' and extra == 'proxy'", specifier = ">=0.22.1,<1.0" }, { name = "websockets", marker = "extra == 'proxy'", specifier = ">=15.0.1,<16.0" }, @@ -4769,12 +4773,12 @@ proxy-dev = [ [[package]] name = "litellm-enterprise" -version = "0.1.66" +version = "0.1.67" source = { editable = "enterprise" } [[package]] name = "litellm-proxy-extras" -version = "0.4.96" +version = "0.4.97" source = { editable = "litellm-proxy-extras" } [[package]]