litellm/litellm-rust/crates/host-python/src/handle.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

153 lines
5.1 KiB
Rust

use std::panic::{AssertUnwindSafe, catch_unwind};
use crate::{Error, panic_to_pyerr};
use pyo3::exceptions::PyBaseException;
use pyo3::gc::{PyTraverseError, PyVisit};
use pyo3::prelude::*;
pub type StepTypes = for<'py> fn(Python<'py>) -> PyResult<Bound<'py, PyModule>>;
/// What a suspended execution waits on: an awaitable the caller awaits, or a stream
/// boundary the caller's stream reads past.
pub enum Suspension {
Await(Py<PyAny>),
/// The call streams: the caller gets a stream over this execution carrying this head,
/// and the execution stays suspended until the stream asks for a chunk.
StreamOpen(Py<PyAny>),
StreamChunk(Py<PyAny>),
}
enum Resume {
Send(Py<PyAny>),
Throw(PyErr),
}
pub enum ExecutionStep {
Suspended(Suspension),
Return(Py<PyAny>),
}
pub trait ExecutionBody: Send + Sync {
fn resume(&mut self, result: Option<PyResult<Py<PyAny>>>) -> PyResult<ExecutionStep>;
fn traverse(&self, visit: &PyVisit<'_>) -> Result<(), PyTraverseError>;
}
enum ExecutionState {
Created(Box<dyn ExecutionBody>),
Running,
Suspended(Box<dyn ExecutionBody>),
Closed,
}
#[pyclass]
pub struct Execution {
state: ExecutionState,
steps: StepTypes,
}
impl Execution {
pub fn new(body: impl ExecutionBody + 'static, steps: StepTypes) -> Self {
Self {
state: ExecutionState::Created(Box::new(body)),
steps,
}
}
fn advance(slf: &Bound<'_, Self>, py: Python<'_>, input: Resume) -> PyResult<Py<PyAny>> {
let (mut body, started) = {
let mut execution = slf.borrow_mut();
let started = match (&execution.state, &input) {
(ExecutionState::Created(_), Resume::Send(value)) if value.is_none(py) => false,
(ExecutionState::Created(_), Resume::Send(_)) => {
return Err(Error::SendBeforeStart.into());
}
(ExecutionState::Suspended(_), _) => true,
(ExecutionState::Running, _) => {
return Err(Error::ExecutionRunning.into());
}
(ExecutionState::Closed, _) => {
return Err(Error::ExecutionClosed.into());
}
(ExecutionState::Created(_), Resume::Throw(_)) => {
return Err(Error::ExecutionOutOfOrder.into());
}
};
match std::mem::replace(&mut execution.state, ExecutionState::Running) {
ExecutionState::Created(body) | ExecutionState::Suspended(body) => (body, started),
_ => unreachable!(),
}
};
let result = match input {
Resume::Send(value) => started.then_some(Ok(value)),
Resume::Throw(error) => Some(Err(error)),
};
let steps = slf.borrow().steps;
let outcome = catch_unwind(AssertUnwindSafe(|| {
let step = body.resume(result)?;
let (tag, value, suspended) = match step {
ExecutionStep::Suspended(Suspension::Await(value)) => ("Await", value, true),
ExecutionStep::Suspended(Suspension::StreamOpen(head)) => {
("StreamOpen", head, true)
}
ExecutionStep::Suspended(Suspension::StreamChunk(chunk)) => {
("StreamChunk", chunk, true)
}
ExecutionStep::Return(value) => ("Complete", value, false),
};
let step = steps(py)?.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(Error::ExecutionClosedWhileRunning.into())
} else {
Ok(step)
}
})
}
}
}
}
#[pymethods]
impl Execution {
fn send(slf: &Bound<'_, Self>, py: Python<'_>, value: Py<PyAny>) -> PyResult<Py<PyAny>> {
Self::advance(slf, py, Resume::Send(value))
}
fn throw(
slf: &Bound<'_, Self>,
py: Python<'_>,
error: Bound<'_, PyBaseException>,
) -> PyResult<Py<PyAny>> {
Self::advance(slf, py, Resume::Throw(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);
}
}