mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-16 23:41:43 +00:00
201 lines
7.1 KiB
Python
201 lines
7.1 KiB
Python
import asyncio
|
|
import time
|
|
from collections.abc import Awaitable, Callable
|
|
from contextlib import ExitStack
|
|
from dataclasses import dataclass
|
|
from typing import Final, Literal
|
|
|
|
import pytest
|
|
from opentelemetry.sdk.trace import ReadableSpan, TracerProvider
|
|
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
|
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
|
from prometheus_client import REGISTRY
|
|
|
|
import litellm
|
|
from litellm.integrations.custom_guardrail import CustomGuardrail
|
|
from litellm.integrations.opentelemetry import LITELLM_REQUEST_SPAN_NAME, OpenTelemetry, OpenTelemetryConfig
|
|
from litellm.integrations.prometheus import PrometheusLogger
|
|
from litellm.types.guardrails import GuardrailEventHooks
|
|
from litellm.types.utils import GenericGuardrailAPIInputs
|
|
from tests.test_litellm_rust.contracts import (
|
|
MESSAGES,
|
|
MESSAGES_MODEL,
|
|
MESSAGES_RESPONSE,
|
|
OCR_RESPONSE,
|
|
call_native_aocr,
|
|
call_native_ocr,
|
|
)
|
|
from tests.test_litellm_rust.recording_server import RecordingServer, ResponseSpec
|
|
from tests.test_litellm_rust.callback_recorder import drain_logging
|
|
|
|
RouteName = Literal["ocr-sync", "ocr-async", "messages", "messages-stream"]
|
|
GuardrailObservation = tuple[Literal["request", "response"], tuple[str, ...]]
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Route:
|
|
name: RouteName
|
|
call_type: str
|
|
provider_model: str
|
|
provider_response: dict[str, object]
|
|
response_text: str
|
|
provider: str
|
|
expected_cost: float
|
|
logging_only_scan: GuardrailObservation
|
|
fires_async_hooks: bool
|
|
|
|
async def invoke(self, server: RecordingServer, **kwargs: object) -> object:
|
|
match self.name:
|
|
case "ocr-sync":
|
|
return await asyncio.to_thread(call_native_ocr, server, **kwargs)
|
|
case "ocr-async":
|
|
return await call_native_aocr(server, **kwargs)
|
|
case "messages":
|
|
return await _call_messages(server, **kwargs)
|
|
case "messages-stream":
|
|
stream: Final = await _call_messages(server, stream=True, **kwargs)
|
|
return [chunk async for chunk in stream]
|
|
|
|
|
|
async def _call_messages(server: RecordingServer, **kwargs: object) -> object:
|
|
return await litellm.anthropic.messages.acreate(
|
|
model=MESSAGES_MODEL,
|
|
messages=MESSAGES,
|
|
max_tokens=64,
|
|
api_key="test-key",
|
|
api_base=server.base_url,
|
|
**kwargs,
|
|
)
|
|
|
|
|
|
MESSAGES_COST: Final = 5 * 3e-06 + 4 * 1.5e-05
|
|
OCR_COST: Final = 0.004
|
|
|
|
OCR_SYNC: Final = Route(
|
|
name="ocr-sync",
|
|
call_type="ocr",
|
|
provider_model="mistral-ocr-latest",
|
|
provider_response=OCR_RESPONSE,
|
|
response_text="native OCR response",
|
|
provider="mistral",
|
|
expected_cost=OCR_COST,
|
|
logging_only_scan=("response", ("native OCR response",)),
|
|
fires_async_hooks=False,
|
|
)
|
|
OCR_ASYNC: Final = Route(
|
|
name="ocr-async",
|
|
call_type="aocr",
|
|
provider_model="mistral-ocr-latest",
|
|
provider_response=OCR_RESPONSE,
|
|
response_text="native OCR response",
|
|
provider="mistral",
|
|
expected_cost=OCR_COST,
|
|
logging_only_scan=("response", ("native OCR response",)),
|
|
fires_async_hooks=True,
|
|
)
|
|
MESSAGES_ROUTE: Final = Route(
|
|
name="messages",
|
|
call_type="anthropic_messages",
|
|
provider_model="claude-sonnet-4-5-20250929",
|
|
provider_response=MESSAGES_RESPONSE,
|
|
response_text="Hello from native Messages",
|
|
provider="anthropic",
|
|
expected_cost=MESSAGES_COST,
|
|
logging_only_scan=("request", ("Hello",)),
|
|
fires_async_hooks=True,
|
|
)
|
|
MESSAGES_STREAM: Final = Route(
|
|
name="messages-stream",
|
|
call_type="anthropic_messages",
|
|
provider_model="claude-sonnet-4-5-20250929",
|
|
provider_response=MESSAGES_RESPONSE,
|
|
response_text="Hello from native Messages",
|
|
provider="anthropic",
|
|
expected_cost=MESSAGES_COST,
|
|
logging_only_scan=("request", ("Hello",)),
|
|
fires_async_hooks=True,
|
|
)
|
|
ALL_ROUTES: Final = (OCR_SYNC, OCR_ASYNC, MESSAGES_ROUTE, MESSAGES_STREAM)
|
|
ASYNC_ROUTES: Final = tuple(route for route in ALL_ROUTES if route.fires_async_hooks)
|
|
NON_STREAM_ASYNC_ROUTES: Final = (OCR_ASYNC, MESSAGES_ROUTE)
|
|
|
|
|
|
def route_id(route: Route) -> str:
|
|
return route.name
|
|
|
|
|
|
@pytest.fixture
|
|
def provider(recording_server: RecordingServer, route: Route) -> RecordingServer:
|
|
recording_server.default_response = ResponseSpec(body=route.provider_response)
|
|
return recording_server
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class OtelHarness:
|
|
logger: OpenTelemetry
|
|
exporter: InMemorySpanExporter
|
|
|
|
def spans(self, name: str = LITELLM_REQUEST_SPAN_NAME) -> tuple[ReadableSpan, ...]:
|
|
return tuple(span for span in self.exporter.get_finished_spans() if span.name == name)
|
|
|
|
async def wait_for_spans(
|
|
self, name: str = LITELLM_REQUEST_SPAN_NAME, count: int = 1, timeout: float = 10
|
|
) -> tuple[ReadableSpan, ...]:
|
|
deadline: Final = time.monotonic() + timeout
|
|
while len(self.spans(name)) < count:
|
|
if time.monotonic() >= deadline:
|
|
raise TimeoutError(f"Timed out waiting for {count} {name} spans; saw {self.spans(name)}")
|
|
await asyncio.sleep(0.01)
|
|
await drain_logging()
|
|
return self.spans(name)
|
|
|
|
|
|
@pytest.fixture
|
|
def otel(isolate_rust_state: ExitStack) -> OtelHarness:
|
|
exporter: Final = InMemorySpanExporter()
|
|
tracer_provider: Final = TracerProvider()
|
|
isolate_rust_state.callback(tracer_provider.shutdown)
|
|
tracer_provider.add_span_processor(SimpleSpanProcessor(exporter))
|
|
logger: Final = OpenTelemetry(config=OpenTelemetryConfig(exporter=exporter), tracer_provider=tracer_provider)
|
|
return OtelHarness(logger=logger, exporter=exporter)
|
|
|
|
|
|
@pytest.fixture
|
|
def prometheus() -> PrometheusLogger:
|
|
return PrometheusLogger()
|
|
|
|
|
|
def metric_value(name: str, **labels: str) -> float:
|
|
for metric in REGISTRY.collect():
|
|
for sample in metric.samples:
|
|
if sample.name == name and all(sample.labels.get(key) == value for key, value in labels.items()):
|
|
return sample.value
|
|
return 0.0
|
|
|
|
|
|
class RecordingGuardrail(CustomGuardrail):
|
|
def __init__(self, guardrail_name: str = "rust-review", fail_with: Exception | None = None) -> None:
|
|
super().__init__(
|
|
guardrail_name=guardrail_name,
|
|
event_hook=GuardrailEventHooks.logging_only,
|
|
default_on=True,
|
|
)
|
|
self.observations: list[GuardrailObservation] = []
|
|
self._fail_with = fail_with
|
|
|
|
async def apply_guardrail(self, inputs, request_data, input_type, logging_obj=None) -> GenericGuardrailAPIInputs:
|
|
self.observations.append((input_type, tuple(inputs.get("texts") or ())))
|
|
if self._fail_with is not None:
|
|
raise self._fail_with
|
|
return inputs
|
|
|
|
|
|
class ReviewGuardrail(CustomGuardrail):
|
|
def __init__(self, review: Callable[[object], Awaitable[object]]) -> None:
|
|
super().__init__(guardrail_name="rust-review", event_hook=GuardrailEventHooks.post_call, default_on=True)
|
|
self._review = review
|
|
self.call_types: list[object] = []
|
|
|
|
async def async_post_call_success_deployment_hook(self, request_data, response, call_type):
|
|
self.call_types.append(call_type)
|
|
return await self._review(response)
|