litellm/litellm-rust/crates/host-python/src/driver.rs
yujonglee 8302f006dd
refactor(rust-bridge): return an unstarted Execution from native entrypoints and drive it in Python (#46299)
* refactor(rust-bridge): return an unstarted Execution from native entrypoints and drive it in Python

Rust's run_call built Python streams itself and imported
litellm.rust_bridge.streams by name, so sync calls were driven in Rust
while async calls came back as a Rust-made coroutine. Every native
inference entrypoint now returns one unstarted Execution and
public_execution.py drives it with lifecycle.run or lifecycle.drive,
wrapping streams and _hidden_params on the Python side. Rust only
imports the lifecycle step classes through StepTypes, and
python-bridge/src/lifecycle.rs, PythonLifecycle and streams.py are gone.

The adapter trusts the _native.pyi contract with a single cast instead
of a per-request runtime_checkable isinstance. Binding tests assert the
adapter starts the execution and returns its completed value rather
than checking callable identity.

* refactor(rust-bridge): fold lifecycle and public_execution into one execution module

The public stream wrappers only forwarded to the lifecycle streams and added the
header dict, so the streams now carry _hidden_params themselves. lifecycle.py no
longer held any call lifecycle, just the Execution driver, so it is renamed to
execution.py and absorbs the synchronous and asynchronous adapters

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(rust-bridge): keep execution.py free of third-party imports

The host-python tests load execution.py into a bare interpreter, so the
pydantic header validation broke them. The stream head comes from the
binding's encode_stream_head, which always builds a str-keyed dict

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(host-python): preserve pre-async hook coverage after execution handoff

* style(rust-bridge): sort litellm_host_python imports

Co-Authored-By: Yujong Lee <yujong@berri.ai>

* test(rust-bridge): drive unstarted native calls through execution in the split lifecycle tests

Co-Authored-By: Yujong Lee <yujong@berri.ai>

* fix(rust-bridge): mark execution TypeVar as Final

Co-Authored-By: Yujong Lee <yujong@berri.ai>

* test(rust-bridge): cover stream close, read failure, and sync suspension in execution

Co-Authored-By: Yujong Lee <yujong@berri.ai>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-14 17:50:44 -07:00

642 lines
22 KiB
Rust

use std::pin::pin;
use std::sync::Arc;
use std::task::{Context, Poll, Waker};
use parking_lot::{Mutex, MutexGuard};
use pyo3::exceptions::{PyBaseException, PyException, PyRuntimeError};
use pyo3::gc::{PyTraverseError, PyVisit};
use pyo3::prelude::*;
use pyo3::types::PyDict;
use litellm_coroutine::{Coroutine, CoroutineState};
use litellm_host::{
call::HostedCompletion,
lifecycle::{CallEvent, ExecutionEvent, FailureOrigin, Timing, epoch_seconds},
machine::{HostFailure, Machine, MachineStep},
observation::ObservationSender,
protocol::{HostRequest, InterceptRequest, Protocol, Reply, StreamDelivery},
};
use crate::PythonHostCalls;
use crate::caller::{Answer, PythonCaller, Yielded};
use crate::handle::{Execution, ExecutionBody, ExecutionStep, StepTypes};
use crate::hooks::{HookStep, PythonCallEvent, PythonCallHooks};
use crate::native::{NativeMachine, NativePoll};
use crate::{Error, InvokeError, PythonBinding};
type ProtocolOf<H> = <H as PythonBinding>::Protocol;
type ErrorOf<H> = <ProtocolOf<H> as Protocol>::Error;
type ResponseOf<H> = <ProtocolOf<H> as Protocol>::Response;
type Interruption<H> = Option<HostFailure<ErrorOf<H>>>;
type StartMachine<P, M> = Box<
dyn FnOnce(Python<'_>, &Bound<'_, PyDict>, <P as Protocol>::Request) -> PyResult<M>
+ Send
+ Sync,
>;
type Body = Coroutine<Yielded, PyResult<Py<PyAny>>>;
pub struct CallOptions {
pub asynchronous: bool,
pub steps: StepTypes,
pub observers: Option<ObservationSender>,
}
impl CallOptions {
pub fn new(asynchronous: bool, steps: StepTypes) -> Self {
Self {
asynchronous,
steps,
observers: None,
}
}
}
fn is_cancellation(py: Python<'_>, error: &PyErr) -> bool {
!error.is_instance_of::<PyException>(py)
}
struct Observation {
observers: Option<ObservationSender>,
observing: bool,
started_at: f64,
ended_at: Option<f64>,
terminal: Option<CallEvent>,
}
impl Observation {
fn timing(&self) -> Timing {
Timing {
start_time: self.started_at,
end_time: self.ended_at.unwrap_or_else(epoch_seconds),
}
}
fn emit(&self, event: &PythonCallEvent<'_>) {
if let Some(observers) = &self.observers {
observers.emit(event.snapshot());
}
}
fn retain_terminal(&mut self, event: &PythonCallEvent<'_>) {
self.terminal = self.observers.as_ref().map(|_| event.snapshot());
}
fn finish(&mut self) {
if let Some(event) = self.terminal.take()
&& let Some(observers) = &self.observers
{
observers.emit(event);
}
self.observing = false;
}
}
/// What the running call shares with its handle: the binding and hooks the handle
/// traverses and closes, and the Python values the call retains across a suspension.
struct Parts<H, L> {
binding: H,
hooks: L,
arguments: Option<Py<PyDict>>,
interrupted: Option<Py<PyBaseException>>,
selected: Option<Py<PyAny>>,
observation: Observation,
}
type SharedParts<H, L> = Arc<Mutex<Parts<H, L>>>;
enum Setup<E> {
Host(PyErr),
Decode(InvokeError<E>),
}
/// The route's public exception for a native failure. When classification itself fails,
/// that failure is raised with the native error's text as its `__context__`.
fn classified<H: PythonBinding>(py: Python<'_>, binding: &H, error: ErrorOf<H>) -> PyErr {
let native = error.to_string();
let classifier_error = match binding.map_error(py, error) {
Ok(failure) => return failure.into(),
Err(classifier_error) => classifier_error,
};
classifier_error.set_context(py, Some(PyRuntimeError::new_err(native)));
classifier_error
}
/// The call as one async body. Every hook step and host call is awaited in place on the
/// caller's task; the parts lock is never held across an await.
struct CallBody<H, M, L>
where
H: PythonBinding,
M: Machine,
{
caller: PythonCaller,
parts: SharedParts<H, L>,
native: NativeMachine<M>,
start: Option<StartMachine<ProtocolOf<H>, M>>,
streaming: bool,
}
impl<H, M, L> CallBody<H, M, L>
where
L: PythonCallHooks + 'static,
H: PythonBinding + PythonHostCalls<H::Protocol> + 'static,
M: Machine<Protocol = H::Protocol> + 'static,
M::Complete: Into<HostedCompletion<ResponseOf<H>>>,
{
fn parts(&self) -> MutexGuard<'_, Parts<H, L>> {
self.parts.lock()
}
async fn run(mut self) -> PyResult<Py<PyAny>> {
match self.begin().await {
Ok(()) => self.execute().await,
Err(Setup::Host(error)) => self.failure(error, FailureOrigin::Host).await,
Err(Setup::Decode(InvokeError::Python(error))) => {
self.failure(error, FailureOrigin::Call).await
}
Err(Setup::Decode(InvokeError::Native(error))) => self.machine_failed(error).await,
}
}
async fn begin(&mut self) -> Result<(), Setup<ErrorOf<H>>> {
let started_at = epoch_seconds();
let started = {
let mut parts = self.parts();
parts.observation.started_at = started_at;
parts.observation.observing = true;
let event = PythonCallEvent::Started {
start_time: started_at,
};
parts.observation.emit(&event);
Python::attach(|py| parts.hooks.on_event(py, event)).map_err(Setup::Host)?
};
self.settle(started).await.map_err(Setup::Host)?;
let arguments = self
.parts()
.arguments
.take()
.ok_or_else(|| Setup::Host(Error::MissingState.into()))?;
let prepare = {
let mut parts = self.parts();
Python::attach(|py| parts.hooks.prepare_arguments(py, arguments, started_at))
.map_err(Setup::Host)?
};
let prepared = self.settle(prepare).await.map_err(Setup::Host)?;
let request = {
let mut parts = self.parts();
let Parts {
binding,
hooks,
arguments,
..
} = &mut *parts;
Python::attach(|py| {
hooks
.arguments_prepared(py, &prepared)
.map_err(Setup::Host)?;
let decoded = binding.decode_request(py, prepared.bind(py));
*arguments = Some(prepared);
decoded.map_err(Setup::Decode)
})?
};
let start = self
.start
.take()
.ok_or_else(|| Setup::Host(Error::MissingState.into()))?;
let machine = {
let parts = self.parts();
let arguments = parts
.arguments
.as_ref()
.ok_or_else(|| Setup::Host(Error::MissingState.into()))?;
Python::attach(|py| start(py, arguments.bind(py), request)).map_err(Setup::Host)?
};
self.native.start(machine);
Ok(())
}
/// Runs one hook step to its value, handing each awaitable it produces to the caller.
async fn settle<T>(&self, step: HookStep<L, T>) -> PyResult<T> {
let mut step = step;
loop {
let (awaitable, resume) = match step {
HookStep::Ready(value) => return Ok(value),
HookStep::Await(awaitable, resume) => (awaitable, resume),
};
let answer = self.caller.await_python(awaitable).await;
step = {
let mut parts = self.parts();
Python::attach(|py| resume(&mut parts.hooks, py, answer))?
};
}
}
async fn execute(&mut self) -> PyResult<Py<PyAny>> {
let mut interruption: Interruption<H> = None;
loop {
let poll = Python::attach(|py| self.native.resume(py, interruption.take()))?;
let result = match poll {
NativePoll::Ready(result) => result,
NativePoll::Suspend(awaitable) => match self.caller.await_python(awaitable).await {
Ok(_) => self.native.take_result()?,
Err(error) if self.parts().interrupted.is_none() => {
interruption = Some(self.interrupt(error));
continue;
}
Err(error) => return Err(error),
},
};
let op = match result {
Ok(MachineStep::Suspended(op)) => op,
Ok(MachineStep::Complete(completion)) => {
return self.completed(completion).await;
}
Err(error) => return self.machine_failed(error).await,
};
interruption = match self.answer(op).await {
Ok(()) => None,
Err(InvokeError::Native(error)) => Some(HostFailure::Error(error)),
Err(InvokeError::Python(error)) => Some(self.interrupt(error)),
};
}
}
fn interrupt(&self, error: PyErr) -> HostFailure<ErrorOf<H>> {
Python::attach(|py| {
let cancelled = is_cancellation(py, &error);
let native = H::host_error(&error);
self.parts().interrupted = Some(error.into_value(py));
if cancelled {
HostFailure::Cancelled(native)
} else {
HostFailure::Error(native)
}
})
}
/// Answers one machine op. A Python error interrupts the call as raised, a native
/// rejection resumes the machine with it.
async fn answer(
&mut self,
op: HostRequest<ProtocolOf<H>>,
) -> Result<(), InvokeError<ErrorOf<H>>> {
match op {
HostRequest::HostCall(call) => self.host_call(call).await,
HostRequest::Intercept(InterceptRequest::BeforeProviderRequest {
wire,
context,
reply,
}) => {
let step = {
let mut parts = self.parts();
Python::attach(|py| parts.hooks.before_provider_request(py, wire, &context))?
};
let wire = self.settle(step).await?;
reply.send(*wire);
Ok(())
}
HostRequest::Intercept(InterceptRequest::ResultReady { facts, reply }) => {
self.notify(PythonCallEvent::Execution(ExecutionEvent::ResultReady {
facts,
}))
.await?;
reply.send(());
Ok(())
}
HostRequest::Intercept(InterceptRequest::AfterProviderResponse { raw, reply }) => {
self.notify(PythonCallEvent::Execution(
ExecutionEvent::ProviderResponseReceived { raw: &raw },
))
.await?;
reply.send(());
Ok(())
}
HostRequest::Stream(StreamDelivery::Open(head, reply)) => {
self.streaming = true;
let head = {
let mut parts = self.parts();
let Parts { binding, hooks, .. } = &mut *parts;
Python::attach(|py| {
let head = binding.encode_stream_head(py, head)?;
hooks.on_stream_open(py, &head)?;
Ok::<_, PyErr>(head)
})?
};
reply.send(self.caller.open_stream(head).await);
Ok(())
}
HostRequest::Stream(StreamDelivery::Chunk(chunk, reply)) => {
let chunk = {
let mut parts = self.parts();
let Parts { binding, hooks, .. } = &mut *parts;
Python::attach(|py| {
let chunk = binding.encode_chunk(py, chunk)?;
hooks.on_stream_chunk(py, &chunk)?;
Ok::<_, PyErr>(chunk)
})?
};
reply.send(self.caller.yield_chunk(chunk).await);
Ok(())
}
}
}
async fn host_call(
&self,
call: <ProtocolOf<H> as Protocol>::HostCall,
) -> Result<(), InvokeError<ErrorOf<H>>> {
let mut pending = {
let mut parts = self.parts();
Python::attach(|py| parts.binding.begin_host_call(py, call))?
};
while let Some(awaitable) = pending {
let result = self.caller.await_python(awaitable).await;
pending = {
let mut parts = self.parts();
Python::attach(|py| parts.binding.resume_host_call(py, result))?
};
}
Ok(())
}
async fn notify(&self, event: PythonCallEvent<'_>) -> PyResult<()> {
let step = {
let mut parts = self.parts();
parts.observation.emit(&event);
Python::attach(|py| parts.hooks.on_event(py, event))?
};
self.settle(step).await
}
async fn completed(
&mut self,
completion: HostedCompletion<ResponseOf<H>>,
) -> PyResult<Py<PyAny>> {
self.parts().observation.ended_at = Some(epoch_seconds());
let response = match completion {
HostedCompletion::Complete(response) => response,
HostedCompletion::StreamEnded | HostedCompletion::Detached => {
let none = Python::attach(|py| py.None());
return self.succeeded(none).await;
}
};
let encoded = {
let mut parts = self.parts();
Python::attach(|py| parts.binding.encode_response(py, response))
};
let public = match encoded {
Ok(public) => public,
Err(error) => return self.failure(error, FailureOrigin::Call).await,
};
if self.streaming {
return self.succeeded(public).await;
}
let transform = {
let mut parts = self.parts();
let timing = parts.observation.timing();
Python::attach(|py| parts.hooks.transform_response(py, public, timing))
};
let step = match transform {
Ok(step) => step,
Err(error) => return self.failure(error, FailureOrigin::Host).await,
};
match self.settle(step).await {
Ok(response) => self.succeeded(response).await,
Err(error) => self.failure(error, FailureOrigin::Host).await,
}
}
async fn machine_failed(&mut self, error: ErrorOf<H>) -> PyResult<Py<PyAny>> {
let error = {
let mut parts = self.parts();
parts.observation.ended_at.get_or_insert_with(epoch_seconds);
Python::attach(|py| match parts.interrupted.take() {
Some(retained) => PyErr::from_value(retained.into_bound(py).into_any()),
None => classified(py, &parts.binding, error),
})
};
self.failure(error, FailureOrigin::Call).await
}
/// The terminal hooks may suspend. Until they finish, the selected response is held
/// only in the traversed parts, so a cycle through it stays collectable.
async fn succeeded(&mut self, response: Py<PyAny>) -> PyResult<Py<PyAny>> {
let step = {
let mut parts = self.parts();
let timing = parts.observation.timing();
let event = PythonCallEvent::Succeeded {
timing,
response: &response,
};
parts.observation.retain_terminal(&event);
let step = Python::attach(|py| parts.hooks.on_event(py, event))?;
parts.selected = Some(response);
step
};
self.settle(step).await?;
let mut parts = self.parts();
parts.observation.finish();
parts
.selected
.take()
.ok_or_else(|| Error::MissingState.into())
}
/// As [`Self::succeeded`], with the selected exception held as its value and raised
/// again once the hooks finish.
async fn failure(&mut self, error: PyErr, origin: FailureOrigin) -> PyResult<Py<PyAny>> {
let step = {
let mut parts = self.parts();
parts.observation.ended_at.get_or_insert_with(epoch_seconds);
let timing = parts.observation.timing();
Python::attach(|py| {
if is_cancellation(py, &error) {
return Err(error);
}
let event = PythonCallEvent::Failed {
timing,
origin,
error: &error,
};
parts.observation.retain_terminal(&event);
let step = parts.hooks.on_event(py, event)?;
parts.selected = Some(error.into_value(py).into_any());
Ok(step)
})?
};
self.settle(step).await?;
let mut parts = self.parts();
parts.observation.finish();
let selected = parts.selected.take().ok_or(Error::MissingState)?;
Err(Python::attach(|py| {
PyErr::from_value(selected.into_bound(py))
}))
}
}
struct CallDriver<H, L>
where
H: PythonBinding,
L: PythonCallHooks,
{
body: Mutex<Body>,
reply: Option<Reply<Answer>>,
parts: SharedParts<H, L>,
}
impl<H, L> CallDriver<H, L>
where
H: PythonBinding + 'static,
L: PythonCallHooks + 'static,
{
/// A failure the body raised past its terminal hooks, or a cancellation, is the last
/// thing the observers hear about the call.
fn observe_host_failure(&self, error: &PyErr) {
let mut parts = self.parts.lock();
if !parts.observation.observing {
return;
}
let timing = parts.observation.timing();
Python::attach(|py| {
let event = if is_cancellation(py, error) {
PythonCallEvent::Cancelled { timing }
} else {
PythonCallEvent::Failed {
timing,
origin: FailureOrigin::Host,
error,
}
};
parts.observation.emit(&event);
});
parts.observation.observing = false;
parts.observation.terminal = None;
}
}
impl<H, L> ExecutionBody for CallDriver<H, L>
where
H: PythonBinding + 'static,
L: PythonCallHooks + 'static,
{
fn resume(&mut self, answer: Option<Answer>) -> PyResult<ExecutionStep> {
match (self.reply.take(), answer) {
(Some(reply), Some(answer)) => reply.send(answer),
(None, None) => {}
(Some(_), None) | (None, Some(_)) => return Err(Error::MissingState.into()),
}
let step = {
let body = self.body.get_mut();
let mut resume = pin!(body.resume());
match resume
.as_mut()
.poll(&mut Context::from_waker(Waker::noop()))
{
Poll::Ready(step) => step,
Poll::Pending => return Err(Error::MissingState.into()),
}
};
match step {
Ok(CoroutineState::Yielded(Yielded { suspension, reply })) => {
self.reply = Some(reply);
Ok(ExecutionStep::Suspended(suspension))
}
Ok(CoroutineState::Complete(Ok(value))) => Ok(ExecutionStep::Return(value)),
Ok(CoroutineState::Complete(Err(error))) => {
self.observe_host_failure(&error);
Err(error)
}
Err(_) => Err(Error::MissingState.into()),
}
}
fn traverse(&self, visit: &PyVisit<'_>) -> Result<(), PyTraverseError> {
let Some(parts) = self.parts.try_lock() else {
return Ok(());
};
parts.binding.traverse(visit)?;
parts.hooks.traverse(visit)?;
visit.call(&parts.arguments)?;
visit.call(&parts.interrupted)?;
visit.call(&parts.selected)
}
}
impl<H, L> Drop for CallDriver<H, L>
where
H: PythonBinding,
L: PythonCallHooks,
{
fn drop(&mut self) {
self.body.get_mut().cancel();
self.reply = None;
let mut parts = self.parts.lock();
Python::attach(|py| {
if parts.observation.observing {
let timing = parts.observation.timing();
parts
.observation
.emit(&PythonCallEvent::Cancelled { timing });
parts.observation.observing = false;
}
parts.hooks.close(py);
parts.binding.close(py);
});
}
}
pub fn run_call<H, M, L>(
py: Python<'_>,
start: impl FnOnce(
Python<'_>,
&Bound<'_, PyDict>,
<H::Protocol as Protocol>::Request,
) -> PyResult<M>
+ Send
+ Sync
+ 'static,
binding: H,
hooks: L,
arguments: Py<PyDict>,
options: CallOptions,
) -> PyResult<Py<PyAny>>
where
L: PythonCallHooks + 'static,
H: PythonBinding + PythonHostCalls<H::Protocol> + 'static,
M: Machine<Protocol = H::Protocol> + 'static,
M::Complete: Into<HostedCompletion<ResponseOf<H>>>,
{
let parts = Arc::new(Mutex::new(Parts {
binding,
hooks,
arguments: Some(arguments),
interrupted: None,
selected: None,
observation: Observation {
observers: options.observers,
observing: false,
started_at: 0.0,
ended_at: None,
terminal: None,
},
}));
let body = Coroutine::new({
let parts = Arc::clone(&parts);
let asynchronous = options.asynchronous;
move |co| {
CallBody {
caller: PythonCaller::new(co),
parts,
native: NativeMachine::new(asynchronous),
start: Some(Box::new(start)),
streaming: false,
}
.run()
}
});
let driver = CallDriver {
body: Mutex::new(body),
reply: None,
parts,
};
Py::new(py, Execution::new(driver, options.steps)).map(Py::into_any)
}