Files
microsoft--agent-lightning/tests/llm_proxy/test_llm_proxy_cpu.py
T
wehub-resource-sync 85742ab165
Deploy Documentation / deploy (push) Has been cancelled
CPU Test / Test (Utilities, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (LLM proxy, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Others, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Store, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Utilities, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Weave, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (AgentOps, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (LLM proxy, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (Others, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (Weave, latest, Python 3.13) (push) Has been cancelled
Dashboard / Chromatic (push) Has been cancelled
CPU Test / Lint - fast (push) Has been cancelled
CPU Test / Lint - next (push) Has been cancelled
CPU Test / Lint - slow (push) Has been cancelled
CPU Test / Lint - JavaScript (push) Has been cancelled
CPU Test / Build documentation (push) Has been cancelled
CPU Test / Test (AgentOps, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (LLM proxy, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (Others, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (Store, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (Weave, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (AgentOps, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Store, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (Utilities, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (Weave, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (AgentOps, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (LLM proxy, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (Others, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (Store, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (Utilities, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (JavaScript) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:44:17 +08:00

383 lines
13 KiB
Python

# Copyright (c) Microsoft. All rights reserved.
import asyncio
import logging
import multiprocessing
import random
from typing import Any, List, cast
import litellm
import openai
import opentelemetry.trace as trace_api
import pytest
from agentops.sdk.core import BatchSpanProcessor
from litellm.llms.custom_llm import CustomLLM
from litellm.types.utils import ModelResponse
from litellm.utils import custom_llm_setup
from opentelemetry.sdk.trace import ReadableSpan
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from agentlightning.llm_proxy import LightningSpanExporter, LLMProxy
from agentlightning.store import LightningStoreServer
from agentlightning.store.memory import InMemoryLightningStore
from agentlightning.store.threading import LightningStoreThreaded
from agentlightning.types import Span
from agentlightning.utils.server_launcher import PythonServerLauncherArgs
from ..common.network import get_free_port
from ..common.tracer import clear_tracer_provider
pytestmark = pytest.mark.llmproxy
class _FakeSpanContext:
def __init__(self, span_id: int):
self.span_id = span_id
class _FakeParent:
def __init__(self, span_id: int):
self.span_id = span_id
class _FakeReadableSpan:
def __init__(self, span_id: int, parent_id: int | None, attrs: dict[str, str]):
self._ctx = _FakeSpanContext(span_id)
self.parent = None if parent_id is None else _FakeParent(parent_id)
self.attributes = attrs
self.name = f"span-{span_id}"
def get_span_context(self):
return self._ctx
class _FakeStore(InMemoryLightningStore):
def __init__(self):
super().__init__()
self.added: list[tuple[str, str, int, _FakeReadableSpan]] = []
async def add_otel_span(
self, rollout_id: str, attempt_id: str, readable_span: ReadableSpan, sequence_id: int | None = None
) -> Span:
assert isinstance(sequence_id, int)
assert isinstance(readable_span, _FakeReadableSpan)
self.added.append((rollout_id, attempt_id, sequence_id, readable_span))
return cast(Span, None)
@pytest.mark.asyncio
async def test_exporter_tree_and_flush_headers_parsing():
store = _FakeStore()
exporter = LightningSpanExporter(store)
# Build a root and two children. Headers distributed across spans.
root = _FakeReadableSpan(1, None, {"metadata.requester_custom_headers": "{'x-rollout-id': 'r1'}"})
child_a = _FakeReadableSpan(2, 1, {"metadata.requester_custom_headers": "{'x-attempt-id': 'a9'}"})
child_b = _FakeReadableSpan(3, 1, {"metadata.requester_custom_headers": "{'x-sequence-id': '7'}"})
# Push to buffer and export
res = exporter.export(cast(List[ReadableSpan], [root, child_a, child_b]))
assert res.name == "SUCCESS"
# Give event loop a moment to run exporter coroutine
await asyncio.sleep(0.1)
# Should have flushed all three with merged headers
assert len(store.added) == 3
for rid, aid, sid, sp in store.added:
assert rid == "r1"
assert aid == "a9"
assert sid == 7
assert isinstance(sp, _FakeReadableSpan)
exporter.shutdown()
def test_exporter_helpers():
store = _FakeStore()
exporter = LightningSpanExporter(store)
# Tree: 10(root) -> 11(child) -> 12(grandchild); 20(root2)
s10 = _FakeReadableSpan(10, None, {})
s11 = _FakeReadableSpan(11, 10, {})
s12 = _FakeReadableSpan(12, 11, {})
s20 = _FakeReadableSpan(20, None, {})
for _ in range(10):
exporter._buffer = cast(List[ReadableSpan], [s10, s11, s12, s20]) # pyright: ignore[reportPrivateUsage]
random.shuffle(exporter._buffer) # pyright: ignore[reportPrivateUsage]
roots = list(exporter._get_root_span_ids()) # pyright: ignore[reportPrivateUsage]
assert set(roots) == {10, 20}
subtree_ids = set(exporter._get_subtrees(10)) # pyright: ignore[reportPrivateUsage]
assert subtree_ids == {10, 11, 12}
popped = exporter._pop_subtrees(10) # pyright: ignore[reportPrivateUsage]
assert {sp.get_span_context().span_id for sp in popped} == { # pyright: ignore[reportOptionalMemberAccess]
10,
11,
12,
}
# Remaining buffer has only s20
assert {
sp.get_span_context().span_id # pyright: ignore[reportOptionalMemberAccess]
for sp in exporter._buffer # pyright: ignore[reportPrivateUsage]
} == {20}
exporter.shutdown()
# TODO: add more complex tests for the exporter helper
@pytest.mark.asyncio
async def test_update_model_list():
store = InMemoryLightningStore()
proxy = LLMProxy(
model_list=[
{
"model_name": "gpt-4o-arbitrary",
"litellm_params": {
"model": "openai/gpt-4o",
},
}
],
launch_mode="asyncio",
port=get_free_port(),
store=store,
)
await proxy.start()
assert proxy.is_running()
assert proxy.model_list == [
{
"model_name": "gpt-4o-arbitrary",
"litellm_params": {
"model": "openai/gpt-4o",
},
}
]
proxy.update_model_list(
[
{
"model_name": "gpt-4o-arbitrary",
"litellm_params": {
"model": "openai/gpt-4o-mini",
},
}
]
)
assert proxy.model_list == [
{
"model_name": "gpt-4o-arbitrary",
"litellm_params": {
"model": "openai/gpt-4o-mini",
},
}
]
assert proxy.is_running()
await proxy.stop()
@pytest.mark.asyncio
async def test_restart_resets_litellm_logging_worker() -> None:
"""LLMProxy.start() should recreate LiteLLM's logging worker on each run."""
try:
from litellm.litellm_core_utils import logging_worker as litellm_logging_worker
except ImportError:
pytest.skip("LiteLLM logging worker not available")
store = InMemoryLightningStore()
proxy = LLMProxy(
model_list=[
{
"model_name": "dummy-model",
# The backend is never invoked; only the proxy lifecycle matters here.
"litellm_params": {"model": "gpt-3.5-turbo"},
}
],
store=store,
launcher_args=PythonServerLauncherArgs(
port=get_free_port(),
launch_mode="asyncio",
healthcheck_url="/health",
startup_timeout=10.0,
process_join_timeout=10.0,
),
)
try:
await proxy.start()
first_worker = litellm_logging_worker.GLOBAL_LOGGING_WORKER
await proxy.stop()
await proxy.start()
second_worker = litellm_logging_worker.GLOBAL_LOGGING_WORKER
finally:
await proxy.stop()
assert first_worker is not second_worker, "LiteLLM logging worker should be refreshed after restart"
class TestLLM(CustomLLM):
def __init__(self, content: str) -> None:
super().__init__()
self.content = content
def completion(self, *args: Any, **kwargs: Any) -> ModelResponse:
return litellm.completion( # type: ignore
model="gpt-4o",
messages=[{"role": "user", "content": "Hello world"}],
mock_response=self.content,
)
async def acompletion(self, *args: Any, **kwargs: Any) -> ModelResponse:
return litellm.completion( # type: ignore
model="gpt-4o",
messages=[{"role": "user", "content": "Hello world"}],
mock_response=self.content,
)
@pytest.mark.asyncio
async def test_custom_llm_restarted_multiple_times(caplog: pytest.LogCaptureFixture) -> None:
clear_tracer_provider()
restart_times: int = 30
store = LightningStoreThreaded(InMemoryLightningStore())
caplog.set_level(logging.WARNING)
port = get_free_port()
try:
llm_proxy = LLMProxy(
model_list=[
{
"model_name": "gpt-4o-arbitrary",
"litellm_params": {
# NOTE: The model after "/" cannot be an openai model like gpt-4o
# This might be a bug with litellm
"model": "test-llm/any-llm",
},
}
],
launcher_args=PythonServerLauncherArgs(
launch_mode="thread",
healthcheck_url="/health",
port=port,
),
store=store,
)
for restart_idx in range(restart_times):
llm_instance = TestLLM(f"Hi! {restart_idx}")
litellm.custom_provider_map = [{"provider": "test-llm", "custom_handler": llm_instance}]
custom_llm_setup()
await llm_proxy.restart()
assert llm_proxy.is_running()
openai_client = openai.AsyncOpenAI(
base_url=llm_proxy.server_launcher.access_endpoint,
api_key="token-abc123",
timeout=5,
max_retries=0,
)
response = await openai_client.chat.completions.create(
model="gpt-4o-arbitrary",
messages=[{"role": "user", "content": "Hello world"}],
stream=False,
)
assert response.choices[0].message.content == f"Hi! {restart_idx}"
error_logs = [record.message for record in caplog.records if record.levelno >= logging.ERROR]
assert not error_logs, f"Found error logs: {error_logs}"
assert not any("Cannot add callback" in record.message for record in caplog.records)
await llm_proxy.stop()
finally:
litellm.custom_provider_map = []
custom_llm_setup()
async def llm_proxy_span_exporter_loop(otlp_enabled: bool = False):
store = LightningStoreThreaded(InMemoryLightningStore())
if otlp_enabled:
store = LightningStoreServer(store, "127.0.0.1", get_free_port())
await store.start()
llm_instance = TestLLM(f"Hi! I'm a test LLM")
litellm.custom_provider_map = [{"provider": "test-llm", "custom_handler": llm_instance}]
custom_llm_setup()
proxy = LLMProxy(
launcher_args=PythonServerLauncherArgs(
launch_mode="thread",
healthcheck_url="/health",
port=get_free_port(),
),
store=store,
model_list=[
{
"model_name": "gpt-4o-arbitrary",
"litellm_params": {
"model": "test-llm/any-llm",
},
}
],
)
await proxy.start()
rollout = await store.start_rollout(None)
resource = proxy.as_resource(rollout.rollout_id, rollout.attempt.attempt_id)
client = openai.AsyncOpenAI(
base_url=resource.endpoint,
api_key="token-abc123",
timeout=5,
max_retries=0,
)
response = await client.chat.completions.create(
model="gpt-4o-arbitrary",
messages=[{"role": "user", "content": "Hello world"}],
stream=False,
)
assert response.choices[0].message.content == "Hi! I'm a test LLM"
spans = await store.query_spans(rollout.rollout_id, rollout.attempt.attempt_id)
assert len(spans) > 0, "Should have captured spans"
for span in spans:
assert span.rollout_id == rollout.rollout_id, f"Span {span.name} has incorrect rollout_id"
assert span.attempt_id == rollout.attempt.attempt_id, f"Span {span.name} has incorrect attempt_id"
assert span.sequence_id == 1, f"Span {span.name} has incorrect sequence_id"
tracer_provider = trace_api.get_tracer_provider()
have_asserted_loop = False
for span_processor in tracer_provider._active_span_processor._span_processors: # type: ignore
if isinstance(span_processor, (SimpleSpanProcessor, BatchSpanProcessor)):
if isinstance(span_processor.span_exporter, LightningSpanExporter):
if otlp_enabled:
assert span_processor.span_exporter._loop is None # type: ignore
else:
assert span_processor.span_exporter._loop is not None # type: ignore
have_asserted_loop = True
break
assert have_asserted_loop, f"LightningSpanExporter should be used with otlp_enabled={otlp_enabled}"
await proxy.stop()
if isinstance(store, LightningStoreServer):
await store.stop()
def llm_proxy_span_exporter_loop_sync(otlp_enabled: bool = False):
asyncio.run(llm_proxy_span_exporter_loop(otlp_enabled))
@pytest.mark.parametrize("otlp_enabled", [True, False])
def test_llm_proxy_span_exporter_loop(otlp_enabled: bool):
context = multiprocessing.get_context("spawn")
process = context.Process(target=llm_proxy_span_exporter_loop_sync, args=(otlp_enabled,))
process.start()
process.join(timeout=30.0)
assert process.exitcode == 0