# 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()