Files
microsoft--agent-lightning/tests/llm_proxy/test_llm_proxy_gpu.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

567 lines
23 KiB
Python

# Copyright (c) Microsoft. All rights reserved.
"""Test the LLMProxy class. Still under development.
General TODOs:
1. Add tests for retries
2. Add tests for timeout
3. Add tests for multiple models in model list
4. Add tests for multi-modal models
There are some specific TODOs for each test function.
"""
import ast
import asyncio
import json
from typing import Any, Dict, List, Sequence, Type, Union, cast
import anthropic
import openai
import pytest
from litellm.integrations.custom_logger import CustomLogger
from portpicker import pick_unused_port
from agentlightning import LlmProxyTraceToTriplet
from agentlightning.llm_proxy import LLMProxy, _reset_litellm_logging_worker # pyright: ignore[reportPrivateUsage]
from agentlightning.store import LightningStore, LightningStoreServer, LightningStoreThreaded
from agentlightning.store.memory import InMemoryLightningStore
from agentlightning.types import LLM, Span
from ..common.tracer import clear_tracer_provider
from ..common.vllm import VLLM_VERSION, RemoteOpenAIServer
pytestmark = [pytest.mark.gpu, pytest.mark.llmproxy]
@pytest.fixture(scope="module")
def qwen25_model():
with RemoteOpenAIServer(
model="Qwen/Qwen2.5-0.5B-Instruct",
vllm_serve_args=[
"--gpu-memory-utilization",
"0.7",
"--enable-auto-tool-choice",
"--tool-call-parser",
"hermes",
"--port",
str(pick_unused_port()),
],
) as server:
yield server
def test_qwen25_model_sanity(qwen25_model: RemoteOpenAIServer):
client = qwen25_model.get_client()
response = client.chat.completions.create(
model="Qwen/Qwen2.5-0.5B-Instruct",
messages=[{"role": "user", "content": "Hello, world!"}],
stream=False,
)
assert response.choices[0].message.content is not None
@pytest.mark.asyncio
@pytest.mark.parametrize("otlp_enabled", [True, False])
async def test_basic_integration(qwen25_model: RemoteOpenAIServer, otlp_enabled: bool):
clear_tracer_provider()
inmemory_store = InMemoryLightningStore()
if otlp_enabled:
store = LightningStoreServer(store=inmemory_store, host="127.0.0.1", port=pick_unused_port())
await store.start()
else:
store = LightningStoreThreaded(inmemory_store)
proxy = LLMProxy(
port=pick_unused_port(),
model_list=[
{
"model_name": "gpt-4o-arbitrary",
"litellm_params": {
"model": "hosted_vllm/" + qwen25_model.model,
"api_base": qwen25_model.url_for("v1"),
},
}
],
store=store,
launch_mode="thread" if not otlp_enabled else "mp",
)
rollout = await store.start_rollout(None)
await proxy.start()
resource = proxy.as_resource(rollout.rollout_id, rollout.attempt.attempt_id)
client = openai.OpenAI(base_url=resource.endpoint, api_key="token-abc123")
response = client.chat.completions.create(
model="gpt-4o-arbitrary",
messages=[{"role": "user", "content": "Repeat after me: Hello, world!"}],
stream=False,
)
assert response.choices[0].message.content is not None
assert "hello, world" in response.choices[0].message.content.lower()
await proxy.stop()
spans = await store.query_spans(rollout.rollout_id, rollout.attempt.attempt_id)
if isinstance(store, LightningStoreServer):
await store.stop()
# Verify all spans have correct rollout_id, attempt_id, and sequence_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"
# Verify start time and end time
# TODO: Remove this when this PR is merged: https://github.com/BerriAI/litellm/pull/16558
print(f">>> Span: {span.name}")
print(f">>> Start time: {span.start_time}")
print(f">>> End time: {span.end_time}")
print(f">>> Attributes: {span.attributes.keys()}")
assert span.start_time is not None, f"Span {span.name} has no start time"
assert span.end_time is not None, f"Span {span.name} has no end time"
# Find the raw_gen_ai_request span and verify token IDs
raw_gen_ai_spans = [s for s in spans if s.name == "raw_gen_ai_request"]
assert len(raw_gen_ai_spans) == 1, f"Expected 1 raw_gen_ai_request span, found {len(raw_gen_ai_spans)}"
raw_span = raw_gen_ai_spans[0]
# Verify prompt_token_ids is present and non-empty
assert (
"llm.hosted_vllm.prompt_token_ids" in raw_span.attributes
), "prompt_token_ids not found in raw_gen_ai_request span"
prompt_token_ids: list[int] = ast.literal_eval(raw_span.attributes["llm.hosted_vllm.prompt_token_ids"]) # type: ignore
assert isinstance(prompt_token_ids, list), "prompt_token_ids should be a list"
assert len(prompt_token_ids) > 0, "prompt_token_ids should not be empty"
assert all(isinstance(tid, int) for tid in prompt_token_ids), "All prompt token IDs should be integers"
# Verify response token_ids is present in choices
assert "llm.hosted_vllm.choices" in raw_span.attributes, "choices not found in raw_gen_ai_request span"
choices: list[dict[str, Any]] = ast.literal_eval(raw_span.attributes["llm.hosted_vllm.choices"]) # type: ignore
assert len(choices) > 0, "Should have at least one choice"
if VLLM_VERSION >= (0, 10, 2):
assert "token_ids" in choices[0], "token_ids not found in choice"
response_token_ids: list[int] = choices[0]["token_ids"]
else:
assert (
"llm.hosted_vllm.response_token_ids" in raw_span.attributes
), "response_token_ids not found in raw_gen_ai_request span"
response_token_ids_list: list[list[int]] = ast.literal_eval(raw_span.attributes["llm.hosted_vllm.response_token_ids"]) # type: ignore
assert isinstance(response_token_ids_list, list), "response_token_ids_list should be a list"
assert len(response_token_ids_list) > 0, "response_token_ids_list should not be empty"
assert all(
isinstance(tid_list, list) for tid_list in response_token_ids_list
), "All response token IDs should be lists"
assert all(
isinstance(tid, int) for tid_list in response_token_ids_list for tid in tid_list
), "All response token IDs should be integers"
response_token_ids = response_token_ids_list[0]
assert isinstance(response_token_ids, list), "response token_ids should be a list"
assert len(response_token_ids) > 0, "response token_ids should not be empty"
assert all(isinstance(tid, int) for tid in response_token_ids), "All response token IDs should be integers"
# Find the litellm_request span and verify gen_ai prompts/completions
litellm_spans = [s for s in spans if s.name == "litellm_request"]
assert len(litellm_spans) == 1, f"Expected 1 litellm_request span, found {len(litellm_spans)}"
litellm_span = litellm_spans[0]
# Verify gen_ai.prompt attributes
assert "gen_ai.prompt.0.role" in litellm_span.attributes, "gen_ai.prompt.0.role not found"
assert litellm_span.attributes["gen_ai.prompt.0.role"] == "user", "Expected user role in prompt"
assert "gen_ai.prompt.0.content" in litellm_span.attributes, "gen_ai.prompt.0.content not found"
assert litellm_span.attributes["gen_ai.prompt.0.content"] == "Repeat after me: Hello, world!"
# Verify gen_ai.completion attributes
assert "gen_ai.completion.0.role" in litellm_span.attributes, "gen_ai.completion.0.role not found"
assert litellm_span.attributes["gen_ai.completion.0.role"] == "assistant", "Expected assistant role in completion"
assert "gen_ai.completion.0.content" in litellm_span.attributes, "gen_ai.completion.0.content not found"
assert "gen_ai.completion.0.finish_reason" in litellm_span.attributes, "gen_ai.completion.0.finish_reason not found"
async def _make_proxy_and_store(
qwen25_model: RemoteOpenAIServer,
*,
retries: int = 0,
gunicorn: bool = False,
callbacks: List[Union[Type[CustomLogger], str]] | None = None,
otlp_enabled: bool = False,
):
clear_tracer_provider()
_reset_litellm_logging_worker() # type: ignore
store = InMemoryLightningStore()
if otlp_enabled:
store = LightningStoreServer(store=store, host="127.0.0.1", port=pick_unused_port())
# When the server is forked into subprocess, it automatically becomes a client of the store
await store.start()
else:
# Backward compatibility with legacy thread + non-otlp mode
store = LightningStoreThreaded(store)
proxy = LLMProxy(
model_list=[
{
"model_name": "gpt-4o-arbitrary",
"litellm_params": {
"model": "hosted_vllm/" + qwen25_model.model,
"api_base": qwen25_model.url_for("v1"),
},
}
],
launch_mode="thread" if not otlp_enabled else "mp",
port=pick_unused_port(),
num_workers=4 if gunicorn else 1,
store=store,
num_retries=retries,
callbacks=callbacks,
)
await proxy.start()
return proxy, store
async def _new_resource(proxy: LLMProxy, store: LightningStore):
rollout = await store.start_rollout(None)
return proxy.as_resource(rollout.rollout_id, rollout.attempt.attempt_id), rollout
def _get_client_for_resource(resource: LLM):
return openai.OpenAI(base_url=resource.endpoint, api_key="token-abc123", timeout=120, max_retries=0)
def _get_async_client_for_resource(resource: LLM):
return openai.AsyncOpenAI(base_url=resource.endpoint, api_key="token-abc123", timeout=120, max_retries=0)
def _find_span(spans: Sequence[Span], name: str):
return [s for s in spans if s.name == name]
def _attr(s: Span, key: str, default: Any = None): # type: ignore
return s.attributes.get(key, default)
@pytest.mark.asyncio
@pytest.mark.parametrize("otlp_enabled", [True, False])
async def test_multiple_requests_one_attempt(qwen25_model: RemoteOpenAIServer, otlp_enabled: bool):
proxy, store = await _make_proxy_and_store(qwen25_model, otlp_enabled=otlp_enabled)
try:
resource, rollout = await _new_resource(proxy, store)
client = _get_client_for_resource(resource)
for i in range(3):
r = client.chat.completions.create(
model="gpt-4o-arbitrary",
messages=[{"role": "user", "content": f"Say ping {i}"}],
stream=False,
)
assert r.choices[0].message.content
spans = await store.query_spans(rollout.rollout_id, rollout.attempt.attempt_id)
assert len(spans) > 0
# Different requests have different sequence_ids
assert {s.sequence_id for s in spans} == {1, 2, 3}
# At least 3 requests recorded
assert len(_find_span(spans, "raw_gen_ai_request")) == 3
# TODO: Check response contents and token ids for the 3 requests respectively
finally:
await proxy.stop()
if isinstance(store, LightningStoreServer):
await store.stop()
@pytest.mark.asyncio
@pytest.mark.parametrize("mode", ["gunicorn", "thread", "uvicorn"])
async def test_ten_concurrent_requests(qwen25_model: RemoteOpenAIServer, mode: str):
proxy, store = await _make_proxy_and_store(qwen25_model, gunicorn=mode == "gunicorn", otlp_enabled=mode != "thread")
try:
resource, rollout = await _new_resource(proxy, store)
aclient = _get_async_client_for_resource(resource)
async def _one(i: int):
r = await aclient.chat.completions.create(
model="gpt-4o-arbitrary",
messages=[{"role": "user", "content": f"Return #{i}"}],
stream=False,
)
return r.choices[0].message.content
outs = await asyncio.gather(*[_one(i) for i in range(10)])
assert len([o for o in outs if o]) == 10
await asyncio.sleep(1.0) # Allow some extra time for the spans to be recorded
spans = await store.query_spans(rollout.rollout_id, rollout.attempt.attempt_id)
assert len(_find_span(spans, "raw_gen_ai_request")) == 10
assert {s.sequence_id for s in spans} == {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
# TODO: Check whether the sequence ids get mixed up or not
finally:
await proxy.stop()
if isinstance(store, LightningStoreServer):
await store.stop()
@pytest.mark.asyncio
@pytest.mark.parametrize("otlp_enabled", [True, False])
async def test_anthropic_client_compat(qwen25_model: RemoteOpenAIServer, otlp_enabled: bool):
# litellm proxy accepts Anthropic schema and forwards to OpenAI backend
proxy, store = await _make_proxy_and_store(qwen25_model, otlp_enabled=otlp_enabled)
try:
resource, rollout = await _new_resource(proxy, store)
a = anthropic.Anthropic(base_url=resource.endpoint, api_key="token-abc123", timeout=120)
msg = a.messages.create(
model="gpt-4o-arbitrary",
max_tokens=64,
messages=[{"role": "user", "content": "Respond with the word: OK"}],
)
# Anthropic SDK returns content list
txt = "".join([b.text for b in msg.content if b.type == "text"])
assert "OK" in txt.upper()
spans = await store.query_spans(rollout.rollout_id, rollout.attempt.attempt_id)
assert len(spans) > 0
finally:
await proxy.stop()
if isinstance(store, LightningStoreServer):
await store.stop()
@pytest.mark.asyncio
@pytest.mark.parametrize("otlp_enabled", [True, False])
async def test_tool_call_roundtrip(qwen25_model: RemoteOpenAIServer, otlp_enabled: bool):
proxy, store = await _make_proxy_and_store(qwen25_model, otlp_enabled=otlp_enabled)
try:
resource, rollout = await _new_resource(proxy, store)
client = _get_client_for_resource(resource)
tools = [
{
"type": "function",
"function": {
"name": "echo",
"description": "Echo a string",
"parameters": {"type": "object", "properties": {"text": {"type": "string"}}, "required": ["text"]},
},
}
]
r1 = client.chat.completions.create(
model="gpt-4o-arbitrary",
messages=[{"role": "user", "content": "Call the echo tool with text=hello"}],
tools=cast(Any, tools),
tool_choice="auto",
stream=False,
)
# If the small model does not tool-call, skip gracefully
tool_calls = r1.choices[0].message.tool_calls or []
if not tool_calls:
pytest.skip("model did not emit tool calls in this environment")
call = tool_calls[0]
assert call.type == "function"
assert call.function and call.function.name == "echo"
args = json.loads(call.function.arguments)
assert "text" in args
r2 = client.chat.completions.create(
model="gpt-4o-arbitrary",
messages=cast(
Any,
[
{"role": "user", "content": "Call the echo tool with text=hello"},
{
"role": "assistant",
"tool_calls": [
{
"id": call.id,
"type": "function",
"function": {"name": "echo", "arguments": call.function.arguments},
}
],
},
{"role": "tool", "tool_call_id": call.id, "name": "echo", "content": args["text"]},
],
),
stream=False,
)
assert args["text"] in (r2.choices[0].message.content or "")
spans = await store.query_spans(rollout.rollout_id, rollout.attempt.attempt_id)
assert len(_find_span(spans, "litellm_request")) == 2
assert len(_find_span(spans, "raw_gen_ai_request")) == 2
# TODO: Check response contents and token ids for the 2 requests respectively
finally:
await proxy.stop()
if isinstance(store, LightningStoreServer):
await store.stop()
@pytest.mark.asyncio
@pytest.mark.parametrize("otlp_enabled", [True, False])
async def test_streaming_chunks(qwen25_model: RemoteOpenAIServer, otlp_enabled: bool):
proxy, store = await _make_proxy_and_store(qwen25_model, otlp_enabled=otlp_enabled)
try:
resource, rollout = await _new_resource(proxy, store)
client = _get_client_for_resource(resource)
stream = client.chat.completions.create(
model="gpt-4o-arbitrary",
messages=[{"role": "user", "content": "Say the word 'apple'"}],
stream=True,
)
collected: list[str] = []
for evt in stream:
print(f">>> Event: {evt}")
for c in evt.choices:
if c.delta and getattr(c.delta, "content", None):
assert isinstance(c.delta.content, str)
collected.append(c.delta.content)
# Sometimes the model responds with "hello" instead of "apple"
assert "apple" in "".join(collected).lower() or "hello" in "".join(collected).lower()
spans = await store.query_spans(rollout.rollout_id, rollout.attempt.attempt_id)
assert len(spans) > 0
for span in spans:
print(f">>> Span {span.name}: {span.attributes}")
if span.name == "raw_gen_ai_request":
assert "llm.hosted_vllm.prompt_token_ids" in span.attributes
assert "llm.hosted_vllm.choices" in span.attributes
if span.name == "litellm_request":
assert "gen_ai.completion.0.content" in span.attributes
finally:
await proxy.stop()
if isinstance(store, LightningStoreServer):
await store.stop()
@pytest.mark.asyncio
@pytest.mark.parametrize("otlp_enabled", [True, False])
async def test_anthropic_token_ids(qwen25_model: RemoteOpenAIServer, otlp_enabled: bool):
proxy, store = await _make_proxy_and_store(qwen25_model, otlp_enabled=otlp_enabled)
try:
resource, rollout = await _new_resource(proxy, store)
adapter = LlmProxyTraceToTriplet()
client = anthropic.Anthropic(base_url=resource.endpoint, api_key="token-abc123", timeout=120)
# non-stream
response = client.messages.create(
model="gpt-4o-arbitrary",
max_tokens=64,
messages=[{"role": "user", "content": "Say the word: banana"}],
)
txt = "".join([b.text for b in response.content if b.type == "text"])
assert "banana" in txt.lower(), f"Response does not contain 'banana': {txt}"
spans = await store.query_spans(rollout.rollout_id, rollout.attempt.attempt_id)
for i, span in enumerate(spans):
print(f">>> Span {i}: {span.name}, attributes: {span.attributes}")
assert len(spans) > 0
triplets = adapter.adapt(spans)
for i, triplet in enumerate(triplets):
print(f">>> Triplet {i}: {triplet}")
assert len(triplets) == 1
assert triplets[0].prompt["token_ids"]
assert triplets[0].response["token_ids"]
# stream
response = client.messages.create(
model="gpt-4o-arbitrary",
max_tokens=64,
messages=[{"role": "user", "content": "Say the word: banana"}],
stream=True,
)
chunk_number: int = 0
for chunk in response:
print(f">>> Chunk: {chunk}")
chunk_number += 1
assert chunk_number >= 1
spans = await store.query_spans(rollout.rollout_id, rollout.attempt.attempt_id)
for i, span in enumerate(spans):
print(f">>> Span {i}: {span.name}, attributes: {span.attributes}")
if span.name == "raw_gen_ai_request":
assert "llm.hosted_vllm.prompt_token_ids" in span.attributes
assert "llm.hosted_vllm.choices" in span.attributes
if span.name == "litellm_request":
assert "gen_ai.completion.0.content" in span.attributes
assert len(spans) > 0
triplets = adapter.adapt(spans)
for i, triplet in enumerate(triplets):
print(f">>> Triplet {i}: {triplet}")
assert triplet.prompt["token_ids"]
assert triplet.response["token_ids"]
assert len(triplets) == 2
finally:
await proxy.stop()
if isinstance(store, LightningStoreServer):
await store.stop()
class LogprobsCallback(CustomLogger):
async def async_pre_call_hook(self, data: Dict[str, Any], **kwargs: Any) -> Dict[str, Any]: # type: ignore
return {**data, "logprobs": 1}
@pytest.mark.asyncio
@pytest.mark.parametrize("otlp_enabled", [True, False])
async def test_anthropic_logprobs(qwen25_model: RemoteOpenAIServer, otlp_enabled: bool):
proxy, store = await _make_proxy_and_store(
qwen25_model, callbacks=[LogprobsCallback, "return_token_ids", "opentelemetry"], otlp_enabled=otlp_enabled
)
try:
resource, rollout = await _new_resource(proxy, store)
client = anthropic.Anthropic(base_url=resource.endpoint, api_key="token-abc123", timeout=120)
adapter = LlmProxyTraceToTriplet()
# test streaming case only
response = client.messages.create(
model="gpt-4o-arbitrary",
max_tokens=64,
messages=[{"role": "user", "content": "Say the word: banana"}],
stream=True,
)
chunk_number: int = 0
for chunk in response:
print(f">>> Chunk: {chunk}")
chunk_number += 1
assert chunk_number >= 1
spans = await store.query_spans(rollout.rollout_id, rollout.attempt.attempt_id)
for i, span in enumerate(spans):
print(f">>> Span {i}: {span.name}, attributes: {span.attributes}")
if span.name == "raw_gen_ai_request":
assert "llm.hosted_vllm.prompt_token_ids" in span.attributes
assert "llm.hosted_vllm.choices" in span.attributes
choices: list[dict[str, Any]] = ast.literal_eval(span.attributes["llm.hosted_vllm.choices"]) # type: ignore
# Check for token IDs and logprobs in the first choice
assert len(choices) > 0
if VLLM_VERSION >= (0, 10, 2):
assert "token_ids" in choices[0]
assert choices[0]["token_ids"]
assert "logprobs" in choices[0]
assert "content" in choices[0]["logprobs"]
assert len(choices[0]["logprobs"]["content"]) > 0
assert isinstance(choices[0]["logprobs"]["content"][0], dict)
assert "token" in choices[0]["logprobs"]["content"][0]
assert "logprob" in choices[0]["logprobs"]["content"][0]
assert isinstance(choices[0]["logprobs"]["content"][0]["logprob"], float)
assert len(spans) > 0
triplets = adapter.adapt(spans)
for i, triplet in enumerate(triplets):
print(f">>> Triplet {i}: {triplet}")
assert triplet.prompt["token_ids"]
assert triplet.response["token_ids"]
# TODO: Check logprobs
finally:
await proxy.stop()
if isinstance(store, LightningStoreServer):
await store.stop()