mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-13 03:59:12 +00:00
* test: replace 61 live logging and otel tests with offline unit and integration coverage * test: restore datadog formatting, deliver datadog logs and redis failures over the wire, assert full router hook payloads, tighten stream usage and otel checks Restores the pre-existing test_datadog.py lines the branch had reflowed. Datadog success, failure and redis-failure replacements now assert the gzip body posted to the intake, with redis failing through a real cache on a closed local port. Router hook sequence recorder checks the legacy field types and asserts concrete payloads, exact streaming and fallback sequences. Stream usage asserts the default include_usage request body and the redacted messages value. Otel asserts response id and token counts. * test: assert budget envelope figures, guardrail inspection and exact prometheus samples per request * test: isolate the prometheus latency test on its own deployment so counts do not depend on order * test: cover timed slack delivery, keep the redis failure test off the network, and make new payload types read-only * test: drain the redis test's logging and scope otel span checks to the test's own trace * test: drive the periodic slack flush without a wall-clock interval * test: scope router hook events to the test, script the db clock, split a nested comprehension --------- Co-authored-by: yuneng <yuneng@berri.ai>
157 lines
6 KiB
Python
157 lines
6 KiB
Python
import asyncio
|
|
import json
|
|
from collections.abc import Sequence
|
|
from typing import Final
|
|
|
|
import httpx
|
|
import pytest
|
|
import respx
|
|
from opentelemetry.sdk.trace import ReadableSpan, TracerProvider
|
|
from opentelemetry.sdk.trace.export import SimpleSpanProcessor, SpanExportResult
|
|
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
|
|
|
import litellm
|
|
from litellm.integrations.opentelemetry import OpenTelemetry, OpenTelemetryConfig
|
|
|
|
_OPENAI_URL: Final = "https://api.openai.com/v1/chat/completions"
|
|
_EXPECTED_SPAN_NAMES: Final = ("litellm_request", "raw_gen_ai_request")
|
|
_USER: Final = "OTEL_USER"
|
|
_USAGE: Final = {"prompt_tokens": 8, "completion_tokens": 2, "total_tokens": 10}
|
|
_COMPLETION: Final = {
|
|
"id": "chatcmpl-otel",
|
|
"object": "chat.completion",
|
|
"created": 1700000000,
|
|
"model": "gpt-4.1-mini-2025-04-14",
|
|
"service_tier": "default",
|
|
"system_fingerprint": "fp_otel",
|
|
"choices": [{"index": 0, "message": {"role": "assistant", "content": "hello"}, "finish_reason": "stop"}],
|
|
"usage": _USAGE,
|
|
}
|
|
_STREAM: Final = (
|
|
"".join(
|
|
f"data: {json.dumps(chunk)}\n\n"
|
|
for chunk in (
|
|
{
|
|
"id": "chatcmpl-otel",
|
|
"object": "chat.completion.chunk",
|
|
"created": 1700000000,
|
|
"model": "gpt-4.1-mini-2025-04-14",
|
|
"choices": [{"index": 0, "delta": {"role": "assistant", "content": "hello"}, "finish_reason": None}],
|
|
},
|
|
{
|
|
"id": "chatcmpl-otel",
|
|
"object": "chat.completion.chunk",
|
|
"created": 1700000000,
|
|
"model": "gpt-4.1-mini-2025-04-14",
|
|
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
|
|
"usage": _USAGE,
|
|
},
|
|
)
|
|
)
|
|
+ "data: [DONE]\n\n"
|
|
)
|
|
_LITELLM_REQUEST_ATTRIBUTES: Final = (
|
|
"gen_ai.request.model",
|
|
"gen_ai.system",
|
|
"gen_ai.request.temperature",
|
|
"llm.is_streaming",
|
|
"llm.user",
|
|
"gen_ai.response.id",
|
|
"gen_ai.response.model",
|
|
"gen_ai.usage.total_tokens",
|
|
"gen_ai.usage.output_tokens",
|
|
"gen_ai.usage.input_tokens",
|
|
)
|
|
_RAW_STREAMING_ATTRIBUTES: Final = (
|
|
"llm.openai.messages",
|
|
"llm.openai.temperature",
|
|
"llm.openai.user",
|
|
"llm.openai.extra_body",
|
|
"llm.openai.model",
|
|
)
|
|
_RAW_NON_STREAMING_ATTRIBUTES: Final = (
|
|
*_RAW_STREAMING_ATTRIBUTES,
|
|
"llm.openai.id",
|
|
"llm.openai.choices",
|
|
"llm.openai.created",
|
|
"llm.openai.object",
|
|
"llm.openai.service_tier",
|
|
"llm.openai.system_fingerprint",
|
|
"llm.openai.usage",
|
|
)
|
|
|
|
|
|
def _is_our_request(span: ReadableSpan) -> bool:
|
|
return span.name == "litellm_request" and (span.attributes or {}).get("llm.user") == _USER
|
|
|
|
|
|
def _trace_id(span: ReadableSpan) -> int:
|
|
assert span.context is not None
|
|
return span.context.trace_id
|
|
|
|
|
|
class _SignallingExporter(InMemorySpanExporter):
|
|
def __init__(self, loop: asyncio.AbstractEventLoop) -> None:
|
|
super().__init__()
|
|
self.loop: Final = loop
|
|
self.request_span_exported: Final = asyncio.Event()
|
|
|
|
def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
|
|
result: Final = super().export(spans)
|
|
if any(_is_our_request(span) for span in spans):
|
|
self.loop.call_soon_threadsafe(self.request_span_exported.set)
|
|
return result
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("streaming", [True, False])
|
|
async def test_otel_callback_emits_the_request_and_raw_provider_spans(
|
|
streaming: bool, monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter
|
|
) -> None:
|
|
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
|
monkeypatch.delenv("OTEL_SEMCONV_STABILITY_OPT_IN", raising=False)
|
|
exporter: Final = _SignallingExporter(asyncio.get_running_loop())
|
|
tracer_provider: Final = TracerProvider()
|
|
tracer_provider.add_span_processor(SimpleSpanProcessor(exporter))
|
|
monkeypatch.setattr(
|
|
litellm,
|
|
"callbacks",
|
|
[OpenTelemetry(config=OpenTelemetryConfig(exporter=exporter), tracer_provider=tracer_provider)],
|
|
)
|
|
respx_mock.post(_OPENAI_URL).mock(
|
|
return_value=httpx.Response(200, text=_STREAM, headers={"content-type": "text/event-stream"})
|
|
if streaming
|
|
else httpx.Response(200, json=_COMPLETION)
|
|
)
|
|
|
|
response: Final = await litellm.acompletion(
|
|
model="gpt-4.1-mini",
|
|
messages=[{"role": "user", "content": "hi"}],
|
|
temperature=0.1,
|
|
user=_USER,
|
|
stream=streaming,
|
|
api_key="sk-unit-test",
|
|
)
|
|
if streaming:
|
|
assert [chunk async for chunk in response]
|
|
await asyncio.wait_for(exporter.request_span_exported.wait(), timeout=10)
|
|
|
|
finished: Final = exporter.get_finished_spans()
|
|
request_span: Final = next(span for span in finished if _is_our_request(span))
|
|
ours: Final = tuple(span for span in finished if _trace_id(span) == _trace_id(request_span))
|
|
assert tuple(sorted(span.name for span in ours)) == _EXPECTED_SPAN_NAMES
|
|
spans: Final = {span.name: span for span in ours}
|
|
request_attributes: Final = spans["litellm_request"].attributes or {}
|
|
assert all(request_attributes.get(name) is not None for name in _LITELLM_REQUEST_ATTRIBUTES)
|
|
assert request_attributes["gen_ai.request.model"] == "gpt-4.1-mini"
|
|
assert request_attributes["gen_ai.system"] == "openai"
|
|
assert request_attributes["gen_ai.request.temperature"] == 0.1
|
|
assert request_attributes["llm.is_streaming"] == str(streaming)
|
|
assert request_attributes["llm.user"] == _USER
|
|
assert request_attributes["gen_ai.response.id"] == "chatcmpl-otel"
|
|
assert request_attributes["gen_ai.usage.input_tokens"] == _USAGE["prompt_tokens"]
|
|
assert request_attributes["gen_ai.usage.output_tokens"] == _USAGE["completion_tokens"]
|
|
assert request_attributes["gen_ai.usage.total_tokens"] == _USAGE["total_tokens"]
|
|
raw_attributes: Final = spans["raw_gen_ai_request"].attributes or {}
|
|
expected_raw: Final = _RAW_STREAMING_ATTRIBUTES if streaming else _RAW_NON_STREAMING_ATTRIBUTES
|
|
assert all(raw_attributes.get(name) is not None for name in expected_raw)
|