* build(rust): upgrade reqwest to 0.13 and move MCP out of litellm-http The workspace moves to reqwest 0.13, so litellm-http no longer carries a second reqwest just for rmcp. The mcp feature, its pool and its proxy conversion go away, and the gateway hands rmcp a pooled NoRedirect client that already carries the TLS, proxy and timeout settings The Client test helpers collapse into for_test, a no-redirect client without TLS or proxy settings, and wrap_for_test for the few tests that need a hand-built reqwest client Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(http): honor transport settings for Rust WebSockets The Responses WebSocket connected through tokio-tungstenite with its own native-roots TLS, so it ignored the configured CA bundle, verify flag, client certificate, proxies and timeouts. The handshake now goes through a pooled reqwest client (ClientVariant::WebSocket, HTTP/1.1, no redirects) and upgrades the response, so it shares the settings every other outbound request uses The upgrade response is checked for the Sec-WebSocket-Accept key, the Upgrade and Connection headers and a subprotocol that was offered Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(http): build outbound requests as reqwest::Request OutboundRequest was a hand-rolled copy of reqwest::Request. Routes now build a reqwest::Request directly and RequestSigner reads the body bytes back out of it, so the signer still covers exactly what is sent. SigV4 signing moves onto HeaderMap and takes the method, which drops the BTreeMap plumbing and the header casing table Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(http): make litellm_http::Error the one opaque error transport::Error and media::Error fold into litellm_http::Error, an Arc<Repr> newtype whose private variants keep the typed reqwest, io, rustls and tungstenite causes and own every message. Callers can no longer build an error with a message of their own. They ask through dispatched, is_request, status, upstream_response and tls_source One From<reqwest::Error> now classifies connect, network and timeout and strips the URL. The WebSocket handshake reports each failed check as its own variant and owns its connect timeout. OCR drops the media variants it mirrored, and inference::Error maps a header the signer computes to Unsupported for every route, not only chat The error rules move out of AGENTS.md into the rust-error skill, which also records the decisions behind this design Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(rust): split rust-error skill into thin SKILL.md with references Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(rust-error): render causes once and ask predicates at the boundaries The four WebSocket handshake variants collapse into Handshake(HandshakeFault), and media download failures no longer answer dispatched() since they happen before the provider call ErrorDetail in llms and auth-types, the inference error and the OCR error now store their cause in a field named cause with no #[source], so to_string() carries it exactly once and source() is None. Tests that walked the chain now destructure the variant and downcast the cause The gateway asks is_unsupported() and is_missing_credential() on the inference and OCR errors instead of matching through nested enums, and litellm_auth::Error answers is_missing_credential() for both The rust-error skill gains axum and pyo3 references, boundaries.md describes the real predicate order and the known divergences, the testing example asserts dispatched() correctly, and shape, classification, conversions, messages-and-causes and recipes agree with the code again * docs(rust): focus the rust-error gateway page on the error flow and add a rust-gateway skill The former axum page toured gateway-inference: its status match, the envelope table, an extractor, the unsupported route and the Sse closure. Those details belong to the gateway crates and go stale with them. The page is now gateway.md and follows an error from the provider failure through each From into IntoResponse, where it is classified once and rendered by surface and framing. The gateway-structure material moves to a new rust-gateway skill: envelopes per API surface, extractors and path registration, SSE relay, and the router test harness. * test(inference): pin provider URL redaction on every route's transport error Chat, Messages, Responses and transcription each get a route-level test that fails if a reqwest error reaches the caller with its URL. The Messages stream decoder also takes body errors through litellm_http::Error like every other reqwest error Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(rust-error): adopt std source chains and add litellm-error Report Messages describe their own layer and the cause is the source, boundaries render the chain once with litellm_error::Report. Corrects skill claims the audit found stale against the code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(rust-error): keep causes as sources across http, auth, secrets, cache and utility crates Messages describe their own layer and every cause is a typed source, predicates match exhaustively, and boundaries render with litellm_error::Report. Media downloads no longer count as dispatched, Error::upstream bounds the provider body itself, and a forwarded header SigV4 cannot sign is the caller's fault. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(rust-error): keep causes as sources in llms and the gateway crates ErrorDetail loses its string variant, OCR provider variants become data, host faults stop counting as the caller's, a tunnelled transport error classifies the same on every stream path, and each gateway crate has one Error with one status() and extractors that reject in its envelope. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(rust-error): nest llms errors in inference and ask predicates in route tests litellm_inference::Error nests litellm_llms::Error instead of mirroring its variants, host driver faults get their own variant, every predicate matches exhaustively, and route tests ask predicates instead of matching transport variants. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(rust-error): map bridge and gateway-inference errors once and render them with Report RustUpstreamError has one constructor that always sets headers, argument checks raise typed errors mapped once, OCR asks upstream_response(), the Responses stream error event comes from the error type, gateway-inference loses its string variants, and auth faults classify through one litellm_auth predicate on every route. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(rust-error): carry OCR provider headers as a HeaderMap and keep outages off the caller The OCR provider error and get_error_class take reqwest's HeaderMap, the bridge turns it into Python's (name, value) pairs once. Credential acquisition and provider SDK failures are no longer the caller's fault, the UI hides auth and backend causes, and chat keeps a provider answer as is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(rust-error): correct skill claims against the aligned code Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(rust-error): fix the remaining sweep findings in gateway, llms, auth and cache crates Auth rejections hide their backend cause on every inference renderer, path parameters reject in the surface envelope, Textract's unsupported document and Azure DI's unparseable poll URL get typed variants, the credential chain exposes its last failure as the source, gateway-mcp asks predicates over rmcp errors, and the S3 and Azure blob transports classify through litellm_http::Error. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(rust-error): fix the remaining sweep findings on the Python side host-python keeps a native error's chain and returns blocking-pool cancellation instead of panicking, Vault transport failures convert through litellm_http::Error so no URL reaches Python, the bridge asks predicates instead of matching three levels into Vault errors, and the last string-built PyErrs become typed errors mapped once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * build(deps): refresh compatible dependencies flagged by OSV * refactor(rust): remove redundant client construction comment * test(rust): cover configured MCP proxy and CA bundle * fix(http): preserve native WebSocket trust and optional protocols * fix(http): allow plain WebSockets without native roots * fix(http): normalize provider URLs before building outbound requests * fix(rust): preserve OCR download status and reduce error size * fix(rust): preserve upstream bodies and bound diagnostics only * test(rust): preserve bridge serialization boundary in body regression --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
12 KiB
- Target invariants, not completion claims. These supersede the crate guidance below where they conflict
Boundary migration
Keep domain composition here and execution mechanics in litellm-host-python. A helper does not belong in the runtime adapter merely because it uses PyO3. LiteLLM argument rules, provider defaults, public responses, public exception policy and cache or secret-manager compatibility remain product responsibilities
The bridge supplies the Python lifecycle binding and public stream construction to the runtime adapter. Preserve the single inline coroutine driver. Removing the host's hardcoded import must not introduce another driver or a separate asyncio task for caller hooks
src/callable.rs::wrap_failure owns callable exception policy here. Resolved-Future construction uses litellm-host-python::ready_future, passing an already constructed Python value. Keep cache-specific serialization and disabled-cache results here
Implement the migration in separate steps that preserve public API contracts: first defer route resource setup until prepared arguments and preflight are available, then supply the lifecycle binding and separate public stream construction, then relocate the two helpers. Change the host interface and its consumers together in each step. The native.rs rename is optional and comes last
Each step needs focused regression tests in the owning crate and Python integration coverage where the public contract crosses crates. Verify deferred setup and setup failure ordering, caller task and context identity, sync and async streams, exception provenance, cancellation and GC. Use a fresh installed extension to verify Python behavior and update _native.pyi when public signatures change. Do not treat these instructions as evidence that the migration is complete
Existing bridge invariants
- Keep this crate the product-specific PyO3 consumer of
litellm-host-python- Own registration, input projection, the route host and the caller callables it answers operations with (file readers, token providers), public response/error construction and the per-call composition of machine, route host and callback contract
- Legacy callback sharing (the caller's args, kwargs and request object, body/header roots, re-aliasing unchanged body keys) lives in
litellm-callbacks-legacy-pythonbehindPublicCallandLegacyLogging. The bridge hands the public call over and keeps no copy - Value-oriented execution, sync waiting, nested-runtime checks, signal polling and panic containment live in
litellm-host-python. Native async work usespyo3-async-runtimes, Serde output usesPythonized<T> - Core owns typed native state, the route machine, provider preparation/I/O and normalization. The host driver owns terminal events. The legacy adapter in
litellm-callbacks-legacy-pythonownsLoggingdispatch policy - Python, Rust SDK and gateway use one lifecycle-bearing core route entrypoint. Provider helpers stay private, never bridge-accessible transport drivers
- Built-in provider/config/secret/auth/document preparation stays in Rust. Caller-authored callbacks and focused Python-file reads run only at core-selected points
- Target GIL-enabled CPython explicitly with
#[pymodule(gil_used = true)]. Detach Rust-only work- GIL and tokio invariants, each pinned by a test in
host-python(runtime.rs,gil.rs) so a regression fails there before it deadlocks a proxy:- Never hold the GIL while waiting on the runtime. A sync entrypoint releases it with
release_gilaroundblock_on, because every task that attaches would otherwise wait on the thread that is waiting on them (pyo3 parallelism) - Never
block_onfrom a tokio worker. The sync entrypoints refuse with "cannot run from a Tokio context" instead of panicking inside the runtime (tokioRuntime::block_on) - Inside a future,
Python::attachonly for GIL-cheap work: cloning aPy<T>, building a small value, reading a settings snapshot. Anything that can block (a secret manager read, a callback that does I/O, an import, a network call) goes throughlitellm_host_python::attach_blocking, which runs it on the blocking pool so the async workers keep polling other calls (tokiospawn_blocking).block_in_placeis not an alternative: it needs a multi-thread worker and still steals it attach_blockingwork runs on a thread the interpreter did not create (pinned by thethreading.get_ident()test). Like any foreign-thread attach it therefore has no running asyncio loop and a freshcontextvarscontext: do not hand it a coroutine or anything bound to the caller's loop- Dropping the await (an asyncio cancel) does not interrupt the Python call. It runs to completion and its result is discarded. A panic in it reaches the awaiting task as a panic
- Add a case to
gil.rswhen a new seam changes any of these. The tests are the spec
- Never hold the GIL while waiting on the runtime. A sync entrypoint releases it with
- Free-threading requires separate runtime/concurrency validation. Omitting the attribute does not opt out on PyO3 0.28+
- GIL and tokio invariants, each pinned by a test in
- Preserve public argument binding and Python object provenance
- 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
- Conversion errors and every failure after the call starts are terminal
- Disabled/unavailable native execution may select legacy once. Callback exceptions never authorize fallback or replay
- Use one ordinary inline
async defdriver inlitellm/rust_bridge/execution.py, with the native handle and call driver inlitellm-host-python- Contract: generator-style
send(send(None)starts),throw, idempotentclose. Explicitly taggedAwait/Completepreserve awaitable final values - Validate Created/Running/Suspended/Closed protocol states. The machine yields ops, the driver emits one terminal event, the adapter chooses dispatch policy
- Defer effectful setup/context reads/timestamps until start. Unstarted-handle destruction releases inputs independently of Python
finally - Catch only the selected await's errors. Start/resume errors propagate,
GeneratorExitcloses without further awaits - Inline hooks preserve caller task/thread/loop and context writes.
into_futurecreates a separate task and cannot satisfy this contract
- Contract: generator-style
- Finalize fallible public response/error construction, replacements and metadata before terminal dispatch
- Make ownership safe across suspension, re-entry, cancellation and GC
- Keep native provider state typed in core. Do not shuttle it through opaque Python transport/response classes
- Prefer one retained
Py<PyBaseException>viaPyErr::into_value(py). Reconstruct transientPyErrs, 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
- The machine owns its in-flight provider future.
interruptdrops it synchronously, so provider captures are released before the driver returns and no task outlives the call
- Verify behavior through a fresh, provenance-checked installed extension and positive native execution evidence before replacing the custom coroutine
- Cover admitted provider workflows, binding/read-point/identity behavior, failure continuation, finalization, no replay, deferred gates, re-entry, GC and cancellation termination
- Measure real conversion/copy costs before optimizing. Preserve input contracts and capture lifetimes with
PyBackedBytes, and lookup timing when interning names - Ship accurate
_native.pyideclarations and typing markers. Distinguish Future-returning bindings from coroutine-returning bindings
- References: ownership, GC, exception transfer, re-entry
Rules for litellm-rust/crates/python-bridge.
Responsibility
python-bridge is the PyO3 boundary between Python LiteLLM and Rust transforms.
Keep this crate thin. It exposes LiteLLM Rust APIs, assembles domain requests,
maps domain errors to Python exceptions, and delegates generic conversion and
GIL handling to litellm-host-python.
Bridge Shape
- Prefer one stable method per top-level LiteLLM route, for example
messages(...), calling the matchinglitellm-inference-<fmt>entrypoint. - Do not add one exported PyO3 function per provider helper unless there is a measured reason.
- Provider dispatch belongs in the
litellm-inference-*route crate (e.g.litellm_inference_messages), not in this PyO3 crate. - Python owns rollout state and fallback. Rust should return errors. Python
decides whether to raise or fall back. For a rust-only provider/route (no
Python reference), the Python side is a thin dispatch that calls Rust and
raises when the bridge is unavailable, with no fallback.
- Declare it by passing
python=NO_PYTHON(litellm.rust_bridge.runtime) toPublicDispatch.run/arunorruntime.run/arun, never a stand-in callable that raises, and give every context of it aRUST_REQUIREDcatalog rule - Any other decision, an unprojectable call, or a bypass raises
NoPythonImplementationErrorbefore native runs, so a misdeclared route fails in tests instead of reaching deleted code. When deleting a route's Python implementation, switch its dispatch toNO_PYTHONin the same change
- Declare it by passing
- Keep the Python interface minimal (well under 100 lines per route): it only
marshals inputs and calls Rust. Do not add per-route feature flags, and do
not put provider dispatch in
litellm/main.py. It lives in a thin dispatch class underlitellm/llms/<provider>/<route>/.
Data Handling
- OCR payloads can contain personal data and large base64 images. Do not log payloads or provider responses.
- Avoid copying large payloads more than needed. The current JSON round-trip is acceptable for the first scaffold, but future performance work should evaluate direct PyO3 conversion before expanding Rust coverage to image-heavy paths.
- Render a Rust error for Python only in its mapping function, with
litellm_error::Report, neverDebugor a document's contents. An upstream body reaches Python intact as theRustUpstreamErrorargs for provider error mapping. The 256-character cap applies only to HTTP error diagnostics, not response bodies. Do not log those args.
Tests
cargo test --workspacemust compile this crate.- Python tests must cover bridge disabled, bridge enabled, and module-missing fallback behavior for every exposed route.