[Test] L2 & L3 Test Case Stratification Design for Omni Model (#1272)

Signed-off-by: wangyu31577 <wangyu31577@hundsun.com>
Signed-off-by: yenuo26 <410167048@qq.com>
Signed-off-by: wangyu <53896905+yenuo26@users.noreply.github.com>
Signed-off-by: Hongsheng Liu <liuhongsheng4@huawei.com>
Co-authored-by: wangyu31577 <wangyu31577@hundsun.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
This commit is contained in:
authored and GitHub committed 2026-02-12 09:56:35 +08:00
1 parent 82fd2c3637
commit 20a8fe1dcc
20 files changed
+1193 -1183

No files matched your search

+705 -48
View File
@@ -10,11 +10,14 @@ os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
if "VLLM_TARGET_DEVICE" not in os.environ:
os.environ["VLLM_TARGET_DEVICE"] = "cpu"
import concurrent.futures
import gc
import socket
import subprocess
import sys
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@@ -23,12 +26,22 @@ import psutil
import pytest
import torch
import yaml
from openai import OpenAI
from vllm import TextPrompt
from vllm.distributed.parallel_state import cleanup_dist_env_and_memory
from vllm.logger import init_logger
from vllm.utils.network_utils import get_open_port
from vllm_omni.entrypoints.omni import Omni
from vllm_omni.inputs.data import OmniSamplingParams
from vllm_omni.outputs import OmniRequestOutput
logger = init_logger(__name__)
PromptAudioInput = list[tuple[Any, int]] | tuple[Any, int] | None
PromptImageInput = list[Any] | Any | None
PromptVideoInput = list[Any] | Any | None
@pytest.fixture(autouse=True)
def default_vllm_config():
@@ -292,6 +305,11 @@ def generate_synthetic_audio(
if max_amp > 0:
audio_data = audio_data / max_amp * 0.8
audio_array = audio_data.copy()
result = {
"np_array": audio_array,
}
# Handle file saving
audio_bytes = None
@@ -318,16 +336,14 @@ def generate_synthetic_audio(
# Return result
base64_audio = base64.b64encode(audio_bytes).decode("utf-8")
result = {
"base64": base64_audio,
}
result["base64"] = base64_audio
if save_to_file and output_path:
result["file_path"] = output_path
return result
def generate_synthetic_video(width: int, height: int, num_frames: int, save_to_file: bool = False) -> str:
def generate_synthetic_video(width: int, height: int, num_frames: int, save_to_file: bool = False) -> dict[str, Any]:
"""Generate synthetic video with bouncing balls and return base64 string."""
import cv2
@@ -397,6 +413,10 @@ def generate_synthetic_video(width: int, height: int, num_frames: int, save_to_f
frame_rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)
video_frames.append(frame_rgb)
video_array = np.array(video_frames)
result = {
"np_array": video_array,
}
video_bytes = None
saved_file_path = None
@@ -451,16 +471,14 @@ def generate_synthetic_video(width: int, height: int, num_frames: int, save_to_f
base64_video = base64.b64encode(video_bytes).decode("utf-8")
result = {
"base64": base64_video,
}
result["base64"] = base64_video
if save_to_file and saved_file_path:
result["file_path"] = saved_file_path
return result
def generate_synthetic_image(width: int, height: int, save_to_file: bool = False) -> Any:
def generate_synthetic_image(width: int, height: int, save_to_file: bool = False) -> dict[str, Any]:
"""Generate synthetic image with randomly colored squares and return base64 string."""
from PIL import Image, ImageDraw
@@ -488,6 +506,9 @@ def generate_synthetic_image(width: int, height: int, save_to_file: bool = False
# Draw square
draw.rectangle([x, y, x + square_size, y + square_size], fill=color, outline=(0, 0, 0), width=border_width)
image_array = np.array(image)
result = {"np_array": image_array.copy()}
# Handle file saving
image_bytes = None
saved_file_path = None
@@ -521,9 +542,7 @@ def generate_synthetic_image(width: int, height: int, save_to_file: bool = False
base64_image = base64.b64encode(image_bytes).decode("utf-8")
# Return result
result = {
"base64": base64_image,
}
result["base64"] = base64_image
if save_to_file and saved_file_path:
result["file_path"] = saved_file_path
@@ -594,7 +613,6 @@ def convert_audio_to_text(audio_data):
audio_file.write(audio_data)
print(f"audio data is saved: {output_path}")
text = convert_audio_file_to_text(output_path=output_path)
return text
@@ -602,7 +620,7 @@ def convert_audio_to_text(audio_data):
def convert_audio_file_to_text(output_path):
import whisper
model = whisper.load_model("base")
model = whisper.load_model("small")
text = model.transcribe(
output_path,
temperature=0.0,
@@ -641,7 +659,7 @@ def merge_base64_and_convert_to_text(base64_list):
def modify_stage_config(
yaml_path: str,
updates: dict[str, Any],
updates: dict[str, Any] = None,
deletes: dict[str, Any] = None,
) -> str:
"""
@@ -792,7 +810,7 @@ def modify_stage_config(
elif isinstance(current, dict) and last_key in current:
del current[last_key]
else:
raise KeyError(f"Path {path} does not exist")
print(f"Path {path} does not exist")
# Apply deletions first
if deletes:
@@ -829,42 +847,43 @@ def modify_stage_config(
# Delete entire key
del config[key]
# Apply updates
for key, value in updates.items():
if key == "stage_args":
if value and isinstance(value, dict):
stage_args = config.get("stage_args", [])
if not stage_args:
raise ValueError("stage_args does not exist in config")
if updates:
# Apply updates
for key, value in updates.items():
if key == "stage_args":
if value and isinstance(value, dict):
stage_args = config.get("stage_args", [])
if not stage_args:
raise ValueError("stage_args does not exist in config")
for stage_id, stage_updates in value.items():
# Find stage by ID
target_stage = None
for stage in stage_args:
if stage.get("stage_id") == stage_id:
target_stage = stage
break
for stage_id, stage_updates in value.items():
# Find stage by ID
target_stage = None
for stage in stage_args:
if stage.get("stage_id") == stage_id:
target_stage = stage
break
if target_stage is None:
available_ids = [s.get("stage_id") for s in stage_args if "stage_id" in s]
raise KeyError(f"Stage ID {stage_id} not found, available: {available_ids}")
if target_stage is None:
available_ids = [s.get("stage_id") for s in stage_args if "stage_id" in s]
raise KeyError(f"Stage ID {stage_id} not found, available: {available_ids}")
# Apply updates to this stage
for path, val in stage_updates.items():
# Check if this is a simple key (not dot-separated)
# Example: 'engine_input_source' vs 'engine_args.max_model_len'
if "." not in path:
# Direct key assignment (e.g., updating a list value)
target_stage[path] = val
else:
# Dot-separated path (e.g., nested dict access)
apply_update(target_stage, path, val)
elif "." in key:
# Apply using dot-separated path
apply_update(config, key, value)
else:
# Direct top-level key
config[key] = value
# Apply updates to this stage
for path, val in stage_updates.items():
# Check if this is a simple key (not dot-separated)
# Example: 'engine_input_source' vs 'engine_args.max_model_len'
if "." not in path:
# Direct key assignment (e.g., updating a list value)
target_stage[path] = val
else:
# Dot-separated path (e.g., nested dict access)
apply_update(target_stage, path, val)
elif "." in key:
# Apply using dot-separated path
apply_update(config, key, value)
else:
# Direct top-level key
config[key] = value
# Save to new file with timestamp
timestamp = int(time.time())
@@ -1012,3 +1031,641 @@ class OmniServer:
_run_pre_test_cleanup(enable_force=True)
_run_post_test_cleanup(enable_force=True)
cleanup_dist_env_and_memory()
def pytest_addoption(parser):
parser.addoption(
"--run-level",
action="store",
default="core_model",
choices=["core_model", "advanced_model"],
help="Test level to run: L2, L3",
)
@pytest.fixture(scope="session")
def run_level(request):
return request.config.getoption("--run-level")
_omni_server_lock = threading.Lock()
@pytest.fixture(scope="module")
def omni_server(request, run_level):
"""Start vLLM-Omni server as a subprocess with actual model weights.
Uses session scope so the server starts only once for the entire test session.
Multi-stage initialization can take 10-20+ minutes.
"""
with _omni_server_lock:
model, stage_config_path = request.param
if run_level == "advanced_model":
stage_config_path = modify_stage_config(
stage_config_path,
deletes={
"stage_args": {
0: ["engine_args.load_format"],
1: ["engine_args.load_format"],
2: ["engine_args.load_format"],
}
},
)
with OmniServer(model, ["--stage-configs-path", stage_config_path, "--stage-init-timeout", "120"]) as server:
print("OmniServer started successfully")
yield server
print("OmniServer stopping...")
print("OmniServer stopped")
@dataclass
class OmniResponse:
text_content: str | None = None
audio_data: list[str] | None = None
audio_content: str | None = None
similarity: float | None = None
e2e_latency: float | None = None
success: bool = False
error_message: str | None = None
def assert_omni_response(response: OmniResponse, request_config: dict[str, Any], run_level):
"""
Validate response results.
Args:
response: OmniResponse object
Raises:
AssertionError: When the response does not meet validation criteria
"""
assert response.success, "The request failed."
e2e_latency = response.e2e_latency
if e2e_latency is not None:
print(f"the avg e2e is: {e2e_latency}")
modalities = request_config.get("modalities", ["text", "audio"])
if "audio" in modalities:
assert response.audio_content is not None, "No audio output is generated"
print(f"audio content is: {response.audio_content}")
if "text" in modalities:
assert response.text_content is not None, "No text output is generated"
print(f"text content is: {response.text_content}")
if run_level == "advanced_model":
# Verify image description
word_types = ["text", "image", "audio", "video"]
keywords_dict = request_config.get("key_words", {})
for word_type in word_types:
keywords = keywords_dict.get(word_type)
if "text" in modalities:
if keywords:
assert any(keyword in response.text_content.lower() for keyword in keywords), (
"The output does not contain any of the keywords."
)
else:
if keywords:
assert any(keyword in response.audio_content.lower() for keyword in keywords), (
"The output does not contain any of the keywords."
)
# Verify similarity
if "text" in modalities and "audio" in modalities:
assert response.similarity > 0.9, "The audio content is not same as the text"
print(f"similarity is: {response.similarity}")
class OpenAIClientHandler:
"""
OpenAI client handler class, encapsulating both streaming and non-streaming response processing logic.
This class integrates OpenAI API request sending, response handling, and validation functionality,
supporting both single request and concurrent request modes.
"""
def __init__(
self, host: str = "127.0.0.1", port: int = get_open_port(), api_key: str = "EMPTY", run_level: str = None
):
"""
Initialize the OpenAI client.
Args:
host: vLLM-Omni server host address
port: vLLM-Omni server port
api_key: API key (defaults to "EMPTY")
"""
self.client = OpenAI(base_url=f"http://{host}:{port}/v1", api_key=api_key)
self.run_level = run_level
def _process_stream_response(self, chat_completion) -> OmniResponse:
"""
Process streaming responses.
Args:
chat_completion: OpenAI streaming response object
request_config: Request configuration dictionary
Returns:
OmniResponse: Processed response object
"""
result = OmniResponse()
start_time = time.perf_counter()
try:
text_content = ""
audio_data = []
for chunk in chat_completion:
for choice in chunk.choices:
# Get content data
if hasattr(choice, "delta"):
content = getattr(choice.delta, "content", None)
else:
content = None
# Get modality type
modality = getattr(chunk, "modality", None)
# Process content based on modality type
if modality == "audio" and content:
audio_data.append(content)
elif modality == "text" and content:
text_content += content if content else ""
# Calculate end-to-end latency
result.e2e_latency = time.perf_counter() - start_time
# Process audio and text content
audio_content = None
similarity = None
if audio_data or text_content:
if audio_data:
audio_content = merge_base64_and_convert_to_text(audio_data)
if audio_content and text_content:
similarity = cosine_similarity_text(audio_content.lower(), text_content.lower())
# Populate result object
result.text_content = text_content
result.audio_data = audio_data
result.audio_content = audio_content
result.similarity = similarity
result.success = True
except Exception as e:
result.error_message = f"Stream processing error: {str(e)}"
print(f"Error: {result.error_message}")
return result
def _process_non_stream_response(self, chat_completion) -> OmniResponse:
"""
Process non-streaming responses.
Args:
chat_completion: OpenAI non-streaming response object
request_config: Request configuration dictionary
Returns:
OmniResponse: Processed response object
"""
result = OmniResponse()
start_time = time.perf_counter()
try:
audio_data = None
text_content = None
# Iterate through all choices
for choice in chat_completion.choices:
# Process audio data
if hasattr(choice.message, "audio") and choice.message.audio is not None:
audio_message = choice.message
audio_data = audio_message.audio.data
# Process text content
if hasattr(choice.message, "content") and choice.message.content is not None:
text_content = choice.message.content
# Calculate end-to-end latency
result.e2e_latency = time.perf_counter() - start_time
# Process audio and text content
audio_content = None
similarity = None
if audio_data or text_content:
if audio_data:
audio_content = convert_audio_to_text(audio_data)
if audio_content and text_content:
similarity = cosine_similarity_text(audio_content.lower(), text_content.lower())
# Populate result object
result.text_content = text_content
result.audio_content = audio_content
result.similarity = similarity
result.success = True
except Exception as e:
result.error_message = f"Non-stream processing error: {str(e)}"
print(f"Error: {result.error_message}")
return result
def send_request(self, request_config: dict[str, Any], request_num: int = 1) -> list[OmniResponse]:
"""
Send OpenAI requests.
Args:
request_config: Request configuration dictionary containing parameters like model, messages, stream
request_num: Number of requests, defaults to 1 (single request)
Returns:
List[OmniResponse]: List of response objects
"""
responses = []
stream = request_config.get("stream", False)
if request_num == 1:
# Send single request
chat_completion = self.client.chat.completions.create(
model=request_config.get("model"), messages=request_config.get("messages"), stream=stream
)
if stream:
response = self._process_stream_response(chat_completion)
else:
response = self._process_non_stream_response(chat_completion)
assert_omni_response(response, request_config, run_level=self.run_level)
responses.append(response)
else:
# Send concurrent requests
with concurrent.futures.ThreadPoolExecutor(max_workers=request_num) as executor:
futures = []
# Submit all request tasks
for _ in range(request_num):
future = executor.submit(
self.client.chat.completions.create,
model=request_config.get("model"),
messages=request_config.get("messages"),
stream=stream,
)
futures.append(future)
# Process completed tasks
for future in concurrent.futures.as_completed(futures):
chat_completion = future.result()
if stream:
response = self._process_stream_response(chat_completion)
else:
response = self._process_non_stream_response(chat_completion)
assert_omni_response(response, request_config, run_level=self.run_level)
responses.append(response)
return responses
@pytest.fixture
def openai_client(omni_server, run_level):
"""Create OpenAIClientHandler fixture"""
return OpenAIClientHandler(host=omni_server.host, port=omni_server.port, api_key="EMPTY", run_level=run_level)
class OmniRunner:
"""
Test runner for Omni models.
"""
def __init__(
self,
model_name: str,
seed: int = 42,
stage_init_timeout: int = 300,
batch_timeout: int = 10,
init_timeout: int = 300,
shm_threshold_bytes: int = 65536,
log_stats: bool = False,
stage_configs_path: str | None = None,
**kwargs,
) -> None:
"""
Initialize an OmniRunner for testing.
Args:
model_name: The model name or path
seed: Random seed for reproducibility
stage_init_timeout: Timeout for initializing a single stage in seconds
batch_timeout: Timeout for batching in seconds
init_timeout: Timeout for initializing stages in seconds
shm_threshold_bytes: Threshold for using shared memory
log_stats: Enable detailed statistics logging
stage_configs_path: Optional path to YAML stage config file
**kwargs: Additional arguments passed to Omni
"""
cleanup_dist_env_and_memory()
_run_pre_test_cleanup(enable_force=True)
_run_post_test_cleanup(enable_force=True)
self.model_name = model_name
self.seed = seed
self.omni = Omni(
model=model_name,
log_stats=log_stats,
stage_init_timeout=stage_init_timeout,
batch_timeout=batch_timeout,
init_timeout=init_timeout,
shm_threshold_bytes=shm_threshold_bytes,
stage_configs_path=stage_configs_path,
**kwargs,
)
def get_default_sampling_params_list(self) -> list[OmniSamplingParams]:
"""
Get a list of default sampling parameters for all stages.
Returns:
List of SamplingParams with default decoding for each stage
"""
return [st.default_sampling_params for st in self.omni.stage_list]
def get_omni_inputs(
self,
prompts: list[str] | str,
system_prompt: str | None = None,
audios: PromptAudioInput = None,
images: PromptImageInput = None,
videos: PromptVideoInput = None,
mm_processor_kwargs: dict[str, Any] | None = None,
modalities: list[str] | None = None,
) -> list[TextPrompt]:
"""
Construct Omni input format from prompts and multimodal data.
Args:
prompts: Text prompt(s) - either a single string or list of strings
system_prompt: Optional system prompt (defaults to Qwen system prompt)
audios: Audio input(s) - tuple of (audio_array, sample_rate) or list of tuples
images: Image input(s) - PIL Image or list of PIL Images
videos: Video input(s) - numpy array or list of numpy arrays
mm_processor_kwargs: Optional processor kwargs (e.g., use_audio_in_video)
Returns:
List of prompt dictionaries suitable for Omni.generate()
"""
if system_prompt is None:
system_prompt = (
"You are Qwen, a virtual human developed by the Qwen Team, Alibaba "
"Group, capable of perceiving auditory and visual inputs, as well as "
"generating text and speech."
)
video_padding_token = "<|VIDEO|>"
image_padding_token = "<|IMAGE|>"
audio_padding_token = "<|AUDIO|>"
if "Qwen3-Omni-30B-A3B-Instruct" in self.model_name:
video_padding_token = "<|video_pad|>"
image_padding_token = "<|image_pad|>"
audio_padding_token = "<|audio_pad|>"
if isinstance(prompts, str):
prompts = [prompts]
def _normalize_mm_input(mm_input, num_prompts):
if mm_input is None:
return [None] * num_prompts
if isinstance(mm_input, list):
if len(mm_input) != num_prompts:
raise ValueError(
f"Multimodal input list length ({len(mm_input)}) must match prompts length ({num_prompts})"
)
return mm_input
return [mm_input] * num_prompts
num_prompts = len(prompts)
audios_list = _normalize_mm_input(audios, num_prompts)
images_list = _normalize_mm_input(images, num_prompts)
videos_list = _normalize_mm_input(videos, num_prompts)
omni_inputs = []
for i, prompt_text in enumerate(prompts):
user_content = ""
multi_modal_data = {}
audio = audios_list[i]
if audio is not None:
if isinstance(audio, list):
for _ in audio:
user_content += f"<|audio_bos|>{audio_padding_token}<|audio_eos|>"
multi_modal_data["audio"] = audio
else:
user_content += f"<|audio_bos|>{audio_padding_token}<|audio_eos|>"
multi_modal_data["audio"] = audio
image = images_list[i]
if image is not None:
if isinstance(image, list):
for _ in image:
user_content += f"<|vision_bos|>{image_padding_token}<|vision_eos|>"
multi_modal_data["image"] = image
else:
user_content += f"<|vision_bos|>{image_padding_token}<|vision_eos|>"
multi_modal_data["image"] = image
video = videos_list[i]
if video is not None:
if isinstance(video, list):
for _ in video:
user_content += f"<|vision_bos|>{video_padding_token}<|vision_eos|>"
multi_modal_data["video"] = video
else:
user_content += f"<|vision_bos|>{video_padding_token}<|vision_eos|>"
multi_modal_data["video"] = video
user_content += prompt_text
full_prompt = (
f"<|im_start|>system\n{system_prompt}<|im_end|>\n"
f"<|im_start|>user\n{user_content}<|im_end|>\n"
f"<|im_start|>assistant\n"
)
input_dict: TextPrompt = {"prompt": full_prompt}
if multi_modal_data:
input_dict["multi_modal_data"] = multi_modal_data
if modalities:
input_dict["modalities"] = modalities
if mm_processor_kwargs:
input_dict["mm_processor_kwargs"] = mm_processor_kwargs
omni_inputs.append(input_dict)
return omni_inputs
def generate(
self,
prompts: list[TextPrompt],
sampling_params_list: list[OmniSamplingParams] | None = None,
) -> list[OmniRequestOutput]:
"""
Generate outputs for the given prompts.
Args:
prompts: List of prompt dictionaries with 'prompt' and optionally
'multi_modal_data' keys
sampling_params_list: List of sampling parameters for each stage.
If None, uses default parameters.
Returns:
List of OmniRequestOutput objects from stages with final_output=True
"""
if sampling_params_list is None:
sampling_params_list = self.get_default_sampling_params_list()
return self.omni.generate(prompts, sampling_params_list)
def generate_multimodal(
self,
prompts: list[str] | str,
sampling_params_list: list[OmniSamplingParams] | None = None,
system_prompt: str | None = None,
audios: PromptAudioInput = None,
images: PromptImageInput = None,
videos: PromptVideoInput = None,
mm_processor_kwargs: dict[str, Any] | None = None,
modalities: list[str] | None = None,
) -> list[OmniRequestOutput]:
"""
Convenience method to generate with multimodal inputs.
Args:
prompts: Text prompt(s)
sampling_params_list: List of sampling parameters for each stage
system_prompt: Optional system prompt
audios: Audio input(s)
images: Image input(s)
videos: Video input(s)
mm_processor_kwargs: Optional processor kwargs
Returns:
List of OmniRequestOutput objects from stages with final_output=True
"""
omni_inputs = self.get_omni_inputs(
prompts=prompts,
system_prompt=system_prompt,
audios=audios,
images=images,
videos=videos,
mm_processor_kwargs=mm_processor_kwargs,
modalities=modalities,
)
return self.generate(omni_inputs, sampling_params_list)
def _cleanup_process(self):
try:
keywords = ["enginecore"]
for proc in psutil.process_iter(["pid", "name", "cmdline", "username"]):
try:
cmdline = " ".join(proc.cmdline()).lower() if proc.cmdline() else ""
name = proc.name().lower()
is_process = any(keyword in cmdline for keyword in keywords) or any(
keyword in name for keyword in keywords
)
if is_process:
print(f"Found vllm process: PID={proc.pid}, cmd={cmdline[:100]}")
try:
proc.terminate()
time.sleep(2)
except Exception:
proc.kill()
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
except Exception as e:
print(f"Error in psutil vllm cleanup: {e}")
def __enter__(self):
"""Context manager entry."""
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Context manager exit - cleanup resources."""
if hasattr(self.omni, "close"):
self.omni.close()
self._cleanup_process()
_run_pre_test_cleanup(enable_force=True)
_run_post_test_cleanup(enable_force=True)
cleanup_dist_env_and_memory()
@pytest.fixture(scope="module")
def omni_runner(request):
with _omni_server_lock:
model, stage_config_path = request.param
with OmniRunner(model, seed=42, stage_configs_path=stage_config_path, stage_init_timeout=300) as runner:
print("OmniRunner started successfully")
yield runner
print("OmniRunner stopping...")
print("OmniRunner stopped")
class OmniRunnerHandler:
def __init__(self, omni_runner):
self.runner = omni_runner
def _process_output(self, outputs: list[Any]) -> OmniResponse:
result = OmniResponse()
try:
text_content = None
audio_content = None
for stage_output in outputs:
if getattr(stage_output, "final_output_type", None) == "text":
text_content = stage_output.request_output[0].outputs[0].text
if getattr(stage_output, "final_output_type", None) == "audio":
audio_content = stage_output.request_output[0].outputs[0].multimodal_output["audio"]
result.audio_content = audio_content
result.text_content = text_content
result.success = True
except Exception as e:
result.error_message = f"Output processing error: {str(e)}"
result.success = False
print(f"Error: {result.error_message}")
return result
def send_request(self, request_config: dict[str, Any] | None = None) -> OmniResponse:
if request_config is None:
request_config = {}
prompts = request_config.get("prompts")
videos = request_config.get("videos")
images = request_config.get("images")
audios = request_config.get("audios")
modalities = request_config.get("modalities", ["text", "audio"])
outputs = self.runner.generate_multimodal(
prompts=prompts, videos=videos, images=images, audios=audios, modalities=modalities
)
response = self._process_output(outputs)
assert_omni_response(response, request_config, run_level="L2")
return response
@pytest.fixture
def omni_runner_handler(omni_runner):
return OmniRunnerHandler(omni_runner)