litellm/tests/sdk_function_trace/runtime.py
yujonglee 198906495f
refactor(python-bridge): split routes and add shared function tracing (#39031)
* refactor(python-bridge): split non-streaming bridge modules

* refactor(python-bridge): bring shared function tracing into route layer

* feat(dev): list Python route functions and call sites

* feat(dev): list Rust route functions and call sites

* docs(dev): record OCR parity gaps across Python and Rust

* feat(dev): list executed SDK calls with runtime tracing

* feat(dev): report Python vs Rust SDK pipeline steps in one CLI

* feat(dev): side-by-side pipeline step report in compare CLI

* fix(dev): drop invalid Final annotations in compare cell loop

* feat(dev): blue python-only and yellow rust-only steps in compare CLI

* feat(dev): vertical layout with section spacing in compare CLI

* fix(dev): validate SDK trace stages across sync and async routes

* refactor(rust): align SDK route call structure with Python

* refactor(python-bridge): share sync and async route call wrappers

* refactor(dev): split compare CLI into fixtures, runtime, and report modules

* fix(ci): run SDK trace tests and satisfy test lint
2026-09-02 16:26:35 -07:00

128 lines
4.5 KiB
Python

from __future__ import annotations
import asyncio
import os
from collections.abc import Awaitable, Generator
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Final, cast
from unittest.mock import patch
from pydantic import BaseModel, ConfigDict
from tests.sdk_function_trace.fixtures import Invocation, sdk_invocation
from tests.sdk_function_trace.mock_provider import mock_provider
from tests.sdk_function_trace.profiler import FunctionTraceEvent, profile_python
from tests.sdk_function_trace.steps import Engine
class TraceEventPayload(BaseModel):
model_config = ConfigDict(strict=True, extra="forbid")
function: str
depth: int
class TraceResponsePayload(BaseModel):
model_config = ConfigDict(strict=True, extra="forbid")
response: object
trace: tuple[TraceEventPayload, ...] | list[TraceEventPayload]
@contextmanager
def _python_engine() -> Generator[None]:
from litellm.rust_bridge import ocr as ocr_bridge
previous_ocr: Final = ocr_bridge.rust_ocr_enabled()
with patch.dict(os.environ, {"LITELLM_RUST": "false"}):
ocr_bridge.use_litellm_rust(False)
try:
yield
finally:
ocr_bridge.use_litellm_rust(previous_ocr)
def _invoke(case: Invocation, api_base: str, *, asynchronous: bool) -> object:
async def invoke_async() -> object:
return await cast("Awaitable[object]", case.function(**case.kwargs, api_base=api_base))
if asynchronous:
return asyncio.run(invoke_async())
return case.function(**case.kwargs, api_base=api_base)
def collect(case: Invocation, api_base: str, *, engine: Engine, asynchronous: bool) -> tuple[FunctionTraceEvent, ...]:
import litellm
if engine == "rust":
payload: Final = TraceResponsePayload.model_validate(_invoke(case, api_base, asynchronous=asynchronous))
return tuple(FunctionTraceEvent(event.function, event.depth) for event in payload.trace)
with profile_python(source_root=Path(litellm.__file__).parent, threads=True) as profiler:
_invoke(case, api_base, asynchronous=asynchronous)
return tuple(profiler.events)
def run_trace(route: str, *, engine: Engine, asynchronous: bool = False) -> tuple[FunctionTraceEvent, ...]:
case: Final = sdk_invocation(route, engine=engine, asynchronous=asynchronous)
with _python_engine(), mock_provider(case.provider_response) as api_base:
events: Final = collect(case, api_base, engine=engine, asynchronous=asynchronous)
if not events:
raise RuntimeError(f"No runtime events for {route}; rebuild the native extension with tracing support")
return events
@dataclass(frozen=True, slots=True)
class TraceOk:
events: tuple[FunctionTraceEvent, ...]
@dataclass(frozen=True, slots=True)
class TraceSkipped:
reason: str
@dataclass(frozen=True, slots=True)
class TraceFailed:
reason: str
TraceRun = TraceOk | TraceSkipped | TraceFailed
def attempt_trace(route: str, *, engine: Engine, asynchronous: bool) -> TraceRun:
try:
return TraceOk(run_trace(route, engine=engine, asynchronous=asynchronous))
except Exception as error:
reason: Final = f"{type(error).__name__}: {error}"
if (
route == "messages"
and engine == "python"
and not asynchronous
and isinstance(error, ValueError)
and str(error) == "anthropic_messages_handler is not implemented for sync calls"
):
return TraceSkipped(reason)
return TraceFailed(reason)
@dataclass(frozen=True, slots=True)
class TraceDiff:
python_only: tuple[str, ...]
rust_only: tuple[str, ...]
shared_order_matches: bool
@property
def matches(self) -> bool:
return not self.python_only and not self.rust_only and self.shared_order_matches
def trace_diff(python: tuple[FunctionTraceEvent, ...], rust: tuple[FunctionTraceEvent, ...]) -> TraceDiff:
python_names: Final = {event.function for event in python}
rust_names: Final = {event.function for event in rust}
shared_python: Final = tuple(event.function for event in python if event.function in rust_names)
shared_rust: Final = tuple(event.function for event in rust if event.function in python_names)
return TraceDiff(
python_only=tuple(event.function for event in python if event.function not in rust_names),
rust_only=tuple(event.function for event in rust if event.function not in python_names),
shared_order_matches=bool(shared_python) and shared_python == shared_rust,
)