2026-08-25 23:32:58 +08:00
|
|
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
|
|
|
|
|
|
2026-01-30 11:41:09 +08:00
|
|
|
|
import asyncio
|
|
|
|
|
|
import contextlib
|
|
|
|
|
|
import io
|
|
|
|
|
|
import json
|
2026-06-04 21:43:35 +08:00
|
|
|
|
import mimetypes
|
2026-01-30 11:41:09 +08:00
|
|
|
|
import os
|
|
|
|
|
|
import random
|
2026-02-13 18:05:07 +08:00
|
|
|
|
import ssl
|
2026-04-15 11:07:35 +08:00
|
|
|
|
import sys
|
2026-01-30 11:41:09 +08:00
|
|
|
|
import time
|
|
|
|
|
|
import traceback
|
2026-08-11 14:53:49 +08:00
|
|
|
|
import uuid
|
2026-05-12 11:56:10 +08:00
|
|
|
|
import wave
|
2026-09-20 17:15:53 +08:00
|
|
|
|
from collections.abc import Callable, Iterable, Iterator, Mapping
|
2026-08-11 23:23:46 +08:00
|
|
|
|
from dataclasses import dataclass, replace
|
2026-01-30 11:41:09 +08:00
|
|
|
|
from datetime import datetime
|
2026-08-12 18:36:31 +08:00
|
|
|
|
from pathlib import Path
|
2026-09-05 16:20:21 +08:00
|
|
|
|
from typing import TYPE_CHECKING, Any, Literal
|
2026-09-22 10:16:39 +08:00
|
|
|
|
from urllib.parse import urlparse
|
2026-01-30 11:41:09 +08:00
|
|
|
|
|
|
|
|
|
|
import aiohttp
|
2026-06-10 11:45:57 +08:00
|
|
|
|
import numpy as np
|
2026-04-30 05:56:59 +08:00
|
|
|
|
import pybase64 as base64
|
2026-09-08 09:28:21 +08:00
|
|
|
|
from PIL import Image
|
2026-01-30 11:41:09 +08:00
|
|
|
|
from tqdm.asyncio import tqdm
|
|
|
|
|
|
from vllm.benchmarks import datasets
|
|
|
|
|
|
from vllm.benchmarks.datasets import SampleRequest
|
|
|
|
|
|
from vllm.benchmarks.lib.endpoint_request_func import (
|
|
|
|
|
|
ASYNC_REQUEST_FUNCS,
|
|
|
|
|
|
OPENAI_COMPATIBLE_BACKENDS,
|
|
|
|
|
|
RequestFuncInput,
|
|
|
|
|
|
RequestFuncOutput,
|
|
|
|
|
|
StreamedResponseHandler,
|
|
|
|
|
|
_get_chat_content,
|
2026-08-25 23:32:58 +08:00
|
|
|
|
_get_headers,
|
2026-01-30 11:41:09 +08:00
|
|
|
|
_update_headers_common,
|
|
|
|
|
|
_update_payload_common,
|
|
|
|
|
|
_validate_api_url,
|
|
|
|
|
|
)
|
|
|
|
|
|
from vllm.logger import init_logger
|
2026-03-09 17:29:38 +08:00
|
|
|
|
from vllm.tokenizers import TokenizerLike
|
2026-01-30 11:41:09 +08:00
|
|
|
|
|
2026-05-20 01:08:56 -04:00
|
|
|
|
from vllm_omni.benchmarks.audio_continuity import compute_continuity_stats
|
2026-08-12 18:36:31 +08:00
|
|
|
|
from vllm_omni.benchmarks.data_modules.daily_omni_dataset import (
|
|
|
|
|
|
DailyOmniDataset,
|
|
|
|
|
|
DailyOmniSampleRequest,
|
|
|
|
|
|
daily_omni_local_qa_json,
|
|
|
|
|
|
daily_omni_local_videos_dir,
|
|
|
|
|
|
resolve_daily_omni_local_root,
|
|
|
|
|
|
)
|
2026-08-25 23:32:58 +08:00
|
|
|
|
from vllm_omni.benchmarks.data_modules.omniinteract_dataset import (
|
|
|
|
|
|
DEFAULT_OMNIINTERACT_REPO,
|
|
|
|
|
|
OmniInteractDataset,
|
2026-09-24 16:34:23 +08:00
|
|
|
|
OmniInteractEvaluationOptions,
|
2026-08-25 23:32:58 +08:00
|
|
|
|
OmniInteractPreparedInput,
|
|
|
|
|
|
OmniInteractSampleRequest,
|
|
|
|
|
|
OmniInteractSessionOptions,
|
|
|
|
|
|
)
|
2026-01-30 11:41:09 +08:00
|
|
|
|
from vllm_omni.benchmarks.data_modules.random_multi_modal_dataset import OmniRandomMultiModalDataset
|
2026-04-15 11:07:35 +08:00
|
|
|
|
from vllm_omni.benchmarks.data_modules.seed_tts_dataset import (
|
|
|
|
|
|
SEED_TTS_DEFAULT_OMNI_SYSTEM_PROMPT,
|
|
|
|
|
|
SeedTTSDataset,
|
2026-04-24 11:35:33 -04:00
|
|
|
|
SeedTTSDesignDataset,
|
2026-04-15 11:07:35 +08:00
|
|
|
|
SeedTTSSampleRequest,
|
2026-04-24 11:35:33 -04:00
|
|
|
|
SeedTTSTextDataset,
|
2026-04-15 11:07:35 +08:00
|
|
|
|
)
|
2026-06-02 15:46:01 +08:00
|
|
|
|
from vllm_omni.benchmarks.data_modules.sound_effect_dataset import SoundEffectDataset
|
|
|
|
|
|
from vllm_omni.benchmarks.data_modules.ttsd_dataset import TTSDDataset
|
2026-09-20 17:15:53 +08:00
|
|
|
|
from vllm_omni.benchmarks.data_modules.videomme_dataset import (
|
|
|
|
|
|
VIDEOMME_DEFAULT_HF_REPO,
|
|
|
|
|
|
VideoMMEDataset,
|
|
|
|
|
|
VideoMMESampleRequest,
|
|
|
|
|
|
resolve_videomme_local_root,
|
|
|
|
|
|
)
|
2026-08-25 23:32:58 +08:00
|
|
|
|
from vllm_omni.benchmarks.omniinteract import (
|
|
|
|
|
|
VIDEO_FPS,
|
|
|
|
|
|
OmniInteractBenchmarkConfig,
|
|
|
|
|
|
OmniInteractCaseResult,
|
2026-09-05 16:20:21 +08:00
|
|
|
|
_audio_rtf_from_raw_metric,
|
2026-08-25 23:32:58 +08:00
|
|
|
|
clear_batch_artifacts,
|
|
|
|
|
|
clear_case_artifacts,
|
|
|
|
|
|
prepare_media,
|
|
|
|
|
|
publish_deferred_case_artifacts,
|
|
|
|
|
|
run_omniinteract_case,
|
|
|
|
|
|
write_failure_artifacts,
|
|
|
|
|
|
)
|
|
|
|
|
|
from vllm_omni.benchmarks.omniinteract import (
|
|
|
|
|
|
benchmark_summary as omniinteract_benchmark_summary,
|
|
|
|
|
|
)
|
|
|
|
|
|
from vllm_omni.benchmarks.omniinteract import (
|
|
|
|
|
|
write_batch_artifacts as write_omniinteract_batch_artifacts,
|
|
|
|
|
|
)
|
2026-09-24 16:34:23 +08:00
|
|
|
|
from vllm_omni.benchmarks.omniinteract_eval import evaluate_batch as evaluate_omniinteract_batch
|
2026-06-04 21:43:35 +08:00
|
|
|
|
from vllm_omni.metrics import definitions as defs
|
2026-09-08 09:28:21 +08:00
|
|
|
|
from vllm_omni.metrics.utils import coerce_bool, coerce_positive_float_scalar, coerce_positive_int_scalar
|
2026-01-30 11:41:09 +08:00
|
|
|
|
|
2026-09-05 16:20:21 +08:00
|
|
|
|
if TYPE_CHECKING:
|
|
|
|
|
|
from vllm_omni.clients.duplex import DuplexClient
|
|
|
|
|
|
|
|
|
|
|
|
# The duplex client library (vllm_omni.clients) is imported lazily inside the
|
|
|
|
|
|
# functions that drive Realtime sessions: this module is imported for its
|
|
|
|
|
|
# patching side effects by the CLI package (`vllm-omni serve` included), and
|
|
|
|
|
|
# the client package must stay out of that import graph
|
|
|
|
|
|
# (tests/engine/test_duplex_import_boundary.py enforces the boundary).
|
|
|
|
|
|
|
2026-07-03 18:15:03 +08:00
|
|
|
|
logger = init_logger(__name__)
|
|
|
|
|
|
|
2026-05-20 01:08:56 -04:00
|
|
|
|
_AUDIO_CONTINUITY_THRESHOLD_ENV = "VLLM_OMNI_BENCH_AUDIO_CONTINUITY_THRESHOLD_S"
|
2026-06-04 21:43:35 +08:00
|
|
|
|
RETURN_STAGE_METRICS_FIELD = "return_stage_metrics"
|
2026-09-08 09:28:21 +08:00
|
|
|
|
_IMAGE_STAGE_METRICS_BACKENDS = frozenset(
|
|
|
|
|
|
{
|
|
|
|
|
|
"/v1/images/generations",
|
|
|
|
|
|
"/v1/images/edits",
|
|
|
|
|
|
"openai-image-edits-omni",
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
2026-06-04 21:43:35 +08:00
|
|
|
|
_PRINT_STAGE = False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def maybe_enable_stage_metrics(extra_body: dict[str, Any] | None, *, enabled: bool) -> dict[str, Any] | None:
|
|
|
|
|
|
"""Return extra_body with stage-metric opt-in when benchmark metrics need it."""
|
|
|
|
|
|
if not enabled:
|
|
|
|
|
|
return extra_body
|
|
|
|
|
|
body = dict(extra_body or {})
|
|
|
|
|
|
body.setdefault(RETURN_STAGE_METRICS_FIELD, True)
|
|
|
|
|
|
return body
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def should_request_stage_metrics(args: Any) -> bool:
|
|
|
|
|
|
"""Whether this benchmark run needs server-side stage metrics in responses."""
|
|
|
|
|
|
if getattr(args, "print_stage", False):
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
backend = getattr(args, "backend", None)
|
|
|
|
|
|
if backend in _IMAGE_STAGE_METRICS_BACKENDS:
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
2026-08-31 00:43:25 +08:00
|
|
|
|
percentile_metrics = getattr(args, "percentile_metrics", ()) or ()
|
|
|
|
|
|
if isinstance(percentile_metrics, str):
|
|
|
|
|
|
percentile_metrics = percentile_metrics.split(",")
|
|
|
|
|
|
selected_metrics = {str(metric).strip().lower() for metric in percentile_metrics}
|
|
|
|
|
|
if backend == "openai-chat-omni" and selected_metrics.intersection({"tpot", "itl"}):
|
|
|
|
|
|
# Client receive timestamps normally provide these metrics. Under
|
|
|
|
|
|
# enough load, however, every text token can be coalesced into one
|
|
|
|
|
|
# event-loop read, leaving no positive interval. Request the engine's
|
|
|
|
|
|
# Stage 0 timings so that path still has an authoritative measurement.
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
2026-06-04 21:43:35 +08:00
|
|
|
|
extra_body = getattr(args, "extra_body", None) or {}
|
|
|
|
|
|
modalities = extra_body.get("modalities") if isinstance(extra_body, dict) else None
|
|
|
|
|
|
return backend == "openai-chat-omni" and "image" in (modalities or [])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def set_print_stage(enabled: bool) -> None:
|
|
|
|
|
|
"""Set whether this benchmark run prints the stage benchmark section."""
|
|
|
|
|
|
global _PRINT_STAGE
|
|
|
|
|
|
_PRINT_STAGE = bool(enabled)
|
2026-05-20 01:08:56 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _audio_continuity_threshold_s() -> float:
|
|
|
|
|
|
"""Return the per-request underrun budget (s).
|
|
|
|
|
|
|
|
|
|
|
|
Read from ``VLLM_OMNI_BENCH_AUDIO_CONTINUITY_THRESHOLD_S`` so users can
|
|
|
|
|
|
re-aim the SLO without rebuilding. Defaults to 100 ms - the standard
|
|
|
|
|
|
"audible gap" budget for streaming TTS.
|
|
|
|
|
|
"""
|
|
|
|
|
|
raw = os.environ.get(_AUDIO_CONTINUITY_THRESHOLD_ENV)
|
|
|
|
|
|
if not raw:
|
2026-06-04 21:43:35 +08:00
|
|
|
|
return defs.AUDIO_CONTINUITY_DEFAULT_THRESHOLD_S
|
2026-05-20 01:08:56 -04:00
|
|
|
|
try:
|
|
|
|
|
|
value = float(raw)
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
"Invalid %s=%r; using default %.3fs",
|
|
|
|
|
|
_AUDIO_CONTINUITY_THRESHOLD_ENV,
|
|
|
|
|
|
raw,
|
2026-06-04 21:43:35 +08:00
|
|
|
|
defs.AUDIO_CONTINUITY_DEFAULT_THRESHOLD_S,
|
2026-05-20 01:08:56 -04:00
|
|
|
|
)
|
2026-06-04 21:43:35 +08:00
|
|
|
|
return defs.AUDIO_CONTINUITY_DEFAULT_THRESHOLD_S
|
2026-05-20 01:08:56 -04:00
|
|
|
|
return max(value, 0.0)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-03 16:00:30 +08:00
|
|
|
|
def _pcm_s16le_to_seed_tts_wer_bytes(
|
|
|
|
|
|
pcm_bytes: bytes,
|
|
|
|
|
|
*,
|
|
|
|
|
|
sample_rate: int,
|
|
|
|
|
|
channels: int,
|
|
|
|
|
|
) -> bytes:
|
|
|
|
|
|
"""Normalize streamed raw PCM to the 24 kHz mono PCM used by Seed-TTS WER."""
|
|
|
|
|
|
if not pcm_bytes:
|
|
|
|
|
|
return b""
|
|
|
|
|
|
channels = max(1, int(channels))
|
|
|
|
|
|
pcm = np.frombuffer(pcm_bytes, dtype=np.int16)
|
|
|
|
|
|
if channels > 1:
|
|
|
|
|
|
usable = (pcm.size // channels) * channels
|
|
|
|
|
|
pcm = pcm[:usable].reshape(-1, channels).mean(axis=1)
|
|
|
|
|
|
pcm_f32 = pcm.astype(np.float32) / 32767.0
|
|
|
|
|
|
if int(sample_rate) != 24000 and pcm_f32.size:
|
|
|
|
|
|
from vllm.multimodal.audio import AudioResampler
|
|
|
|
|
|
|
|
|
|
|
|
resampler = AudioResampler(target_sr=24000)
|
|
|
|
|
|
pcm_f32 = resampler.resample(pcm_f32, orig_sr=int(sample_rate))
|
|
|
|
|
|
pcm_f32 = np.clip(pcm_f32, -1.0, 1.0)
|
|
|
|
|
|
return (pcm_f32 * 32767).astype(np.int16).tobytes()
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-07 17:07:49 -07:00
|
|
|
|
def _stream_pcm_format_from_headers(
|
|
|
|
|
|
headers: Mapping[str, str],
|
|
|
|
|
|
sample_rate: int,
|
|
|
|
|
|
channels: int,
|
|
|
|
|
|
) -> tuple[int, int]:
|
|
|
|
|
|
"""Prefer the server's ``X-Audio-Sample-Rate``/``X-Audio-Channels`` headers.
|
|
|
|
|
|
|
|
|
|
|
|
Raw PCM has no self-describing header; the server states the model-native
|
|
|
|
|
|
format. ``sample_rate``/``channels`` (env-configured) remain the fallback.
|
|
|
|
|
|
"""
|
|
|
|
|
|
resolved = []
|
|
|
|
|
|
for name, fallback in (("X-Audio-Sample-Rate", sample_rate), ("X-Audio-Channels", channels)):
|
|
|
|
|
|
raw = headers.get(name)
|
|
|
|
|
|
value = fallback
|
|
|
|
|
|
if raw:
|
|
|
|
|
|
try:
|
|
|
|
|
|
value = int(raw)
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
logger.warning("Ignoring invalid %s header %r; using %d", name, raw, fallback)
|
|
|
|
|
|
else:
|
|
|
|
|
|
if value != fallback:
|
|
|
|
|
|
logger.debug("Streamed PCM %s=%d from server header (configured %d)", name, value, fallback)
|
|
|
|
|
|
resolved.append(max(value, 1))
|
|
|
|
|
|
return resolved[0], resolved[1]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-01-30 11:41:09 +08:00
|
|
|
|
get_samples_old = datasets.get_samples
|
|
|
|
|
|
|
2026-04-15 11:07:35 +08:00
|
|
|
|
_DEFAULT_DAILY_OMNI_REPO = "liarliar/Daily-Omni"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _seed_tts_capture_pcm_for_wer() -> bool:
|
|
|
|
|
|
return os.environ.get("SEED_TTS_WER_EVAL", "").lower() in (
|
|
|
|
|
|
"1",
|
|
|
|
|
|
"true",
|
|
|
|
|
|
"yes",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-08 17:00:25 +08:00
|
|
|
|
_DEFAULT_REQUEST_TIMEOUT_S = 900.0
|
|
|
|
|
|
_LEGACY_REQUEST_TIMEOUT_S = 6 * 60 * 60.0
|
|
|
|
|
|
|
|
|
|
|
|
# Set from the ``--omni-request-timeout-s`` CLI flag by ``vllm bench serve``
|
|
|
|
|
|
# before the benchmark session is built (``None`` = use the default above).
|
|
|
|
|
|
_REQUEST_TIMEOUT_OVERRIDE_S: float | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def set_request_timeout_s(value: float) -> None:
|
|
|
|
|
|
"""Record the explicitly requested per-request timeout (from the CLI)."""
|
|
|
|
|
|
global _REQUEST_TIMEOUT_OVERRIDE_S
|
|
|
|
|
|
_REQUEST_TIMEOUT_OVERRIDE_S = float(value)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _omni_request_timeout_s() -> float:
|
|
|
|
|
|
"""Per-request total timeout for the shared benchmark ``aiohttp`` session.
|
|
|
|
|
|
|
|
|
|
|
|
An explicit ``--omni-request-timeout-s`` value wins over the 900 s default;
|
|
|
|
|
|
``<= 0`` restores the legacy 6 h cap. A bounded per-request timeout makes a
|
|
|
|
|
|
hung server surface as ``failed`` requests once the deadline fires instead
|
|
|
|
|
|
of pinning the benchmark slot indefinitely.
|
|
|
|
|
|
"""
|
|
|
|
|
|
value = _REQUEST_TIMEOUT_OVERRIDE_S
|
|
|
|
|
|
if value is None:
|
|
|
|
|
|
return _DEFAULT_REQUEST_TIMEOUT_S
|
|
|
|
|
|
if value <= 0:
|
|
|
|
|
|
return _LEGACY_REQUEST_TIMEOUT_S
|
|
|
|
|
|
return value
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build_benchmark_session(
|
|
|
|
|
|
max_concurrency: int | None,
|
|
|
|
|
|
ssl_setting: ssl.SSLContext | bool,
|
|
|
|
|
|
) -> aiohttp.ClientSession:
|
|
|
|
|
|
"""Build the session shared by every benchmark request.
|
|
|
|
|
|
|
|
|
|
|
|
Connections are reused across requests to reduce TLS handshake overhead;
|
|
|
|
|
|
the per-request total timeout comes from ``_omni_request_timeout_s()``.
|
|
|
|
|
|
"""
|
|
|
|
|
|
connector = aiohttp.TCPConnector(
|
|
|
|
|
|
limit=max_concurrency or 0,
|
|
|
|
|
|
limit_per_host=max_concurrency or 0,
|
|
|
|
|
|
ttl_dns_cache=300,
|
|
|
|
|
|
use_dns_cache=True,
|
|
|
|
|
|
enable_cleanup_closed=True,
|
|
|
|
|
|
force_close=True,
|
|
|
|
|
|
ssl=ssl_setting,
|
|
|
|
|
|
)
|
|
|
|
|
|
return aiohttp.ClientSession(
|
|
|
|
|
|
connector=connector,
|
|
|
|
|
|
trust_env=True,
|
|
|
|
|
|
timeout=aiohttp.ClientTimeout(total=_omni_request_timeout_s()),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-15 11:07:35 +08:00
|
|
|
|
def _merge_extra_body_mm_kwargs(base: dict | None, overlay: dict | None) -> dict | None:
|
|
|
|
|
|
"""Shallow-merge ``extra_body`` dicts; deep-merge ``mm_processor_kwargs`` if both set."""
|
|
|
|
|
|
if not base and not overlay:
|
|
|
|
|
|
return None
|
|
|
|
|
|
out = dict(base or {})
|
|
|
|
|
|
if not overlay:
|
|
|
|
|
|
return out
|
|
|
|
|
|
for k, v in overlay.items():
|
|
|
|
|
|
if k == "mm_processor_kwargs" and isinstance(v, dict):
|
|
|
|
|
|
prev = out.get("mm_processor_kwargs")
|
|
|
|
|
|
merged_kw = {**(prev if isinstance(prev, dict) else {}), **v}
|
|
|
|
|
|
out["mm_processor_kwargs"] = merged_kw
|
|
|
|
|
|
else:
|
|
|
|
|
|
out[k] = v
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-20 17:15:53 +08:00
|
|
|
|
def _attach_omni_chat_to_request_func_input(sample: SampleRequest, rfi: RequestFuncInput) -> None:
|
|
|
|
|
|
"""Apply per-request OpenAI fields (``mm_processor_kwargs``, messages) for Daily-Omni / Video-MME."""
|
|
|
|
|
|
if not isinstance(sample, (DailyOmniSampleRequest, VideoMMESampleRequest)):
|
2026-04-15 11:07:35 +08:00
|
|
|
|
return
|
|
|
|
|
|
rfi.extra_body = _merge_extra_body_mm_kwargs(rfi.extra_body, sample.omni_extra_body)
|
|
|
|
|
|
if sample.omni_chat_messages is not None:
|
|
|
|
|
|
setattr(rfi, "omni_chat_messages", sample.omni_chat_messages)
|
2026-09-20 17:15:53 +08:00
|
|
|
|
elif isinstance(sample, DailyOmniSampleRequest):
|
2026-04-15 11:07:35 +08:00
|
|
|
|
setattr(rfi, "mm_position", sample.omni_chat_mm_position)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _attach_seed_tts_to_request_func_input(sample: SampleRequest, rfi: RequestFuncInput) -> None:
|
2026-04-24 11:35:33 -04:00
|
|
|
|
"""Merge Seed-TTS per-row TTS fields into ``extra_body`` and mark for PCM capture.
|
2026-04-15 11:07:35 +08:00
|
|
|
|
|
2026-04-24 11:35:33 -04:00
|
|
|
|
Always sets ``seed_tts_row=True`` on the RequestFuncInput for any
|
|
|
|
|
|
:class:`SeedTTSSampleRequest` subclass (including text-only and design
|
|
|
|
|
|
variants that carry no ``ref_audio``). This enables PCM capture for WER /
|
|
|
|
|
|
UTMOS evaluation even when there is no reference audio.
|
2026-04-15 11:07:35 +08:00
|
|
|
|
"""
|
|
|
|
|
|
if not isinstance(sample, SeedTTSSampleRequest):
|
|
|
|
|
|
return
|
2026-04-24 11:35:33 -04:00
|
|
|
|
# Mark for PCM capture (WER / UTMOS eval) regardless of extra body presence.
|
2026-04-15 11:07:35 +08:00
|
|
|
|
setattr(rfi, "seed_tts_row", True)
|
|
|
|
|
|
sys_prompt = (sample.seed_tts_system_prompt or "").strip() or SEED_TTS_DEFAULT_OMNI_SYSTEM_PROMPT
|
2026-08-11 14:53:49 +08:00
|
|
|
|
setattr(rfi, "seed_tts_system_prompt", sys_prompt)
|
|
|
|
|
|
setattr(rfi, "seed_tts_speech_extra", sample.seed_tts_speech_extra)
|
|
|
|
|
|
setattr(rfi, "seed_tts_turns", sample.seed_tts_turns)
|
2026-04-15 11:07:35 +08:00
|
|
|
|
setattr(
|
|
|
|
|
|
rfi,
|
|
|
|
|
|
"omni_chat_messages",
|
|
|
|
|
|
[
|
|
|
|
|
|
{"role": "system", "content": [{"type": "text", "text": sys_prompt}]},
|
|
|
|
|
|
{"role": "user", "content": [{"type": "text", "text": sample.prompt}]},
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
2026-04-24 11:35:33 -04:00
|
|
|
|
ex = sample.seed_tts_speech_extra
|
|
|
|
|
|
if not ex:
|
|
|
|
|
|
return # voice comes from --extra-body in config; no ref_audio to merge
|
|
|
|
|
|
base = dict(rfi.extra_body) if rfi.extra_body else {}
|
|
|
|
|
|
base.update(ex)
|
|
|
|
|
|
rfi.extra_body = base
|
2026-04-15 11:07:35 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-08-25 23:32:58 +08:00
|
|
|
|
def _attach_omniinteract_to_request_func_input(sample: SampleRequest, rfi: RequestFuncInput) -> None:
|
|
|
|
|
|
if not isinstance(sample, OmniInteractSampleRequest):
|
|
|
|
|
|
return
|
|
|
|
|
|
setattr(rfi, "omniinteract_case", sample.omniinteract_case)
|
|
|
|
|
|
setattr(rfi, "omniinteract_options", sample.omniinteract_options)
|
|
|
|
|
|
setattr(rfi, "omniinteract_prepared_input", sample.omniinteract_prepared_input)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-20 17:15:53 +08:00
|
|
|
|
def _async_limiter(max_concurrency: int | None) -> contextlib.AbstractAsyncContextManager[object]:
|
|
|
|
|
|
if max_concurrency:
|
|
|
|
|
|
return asyncio.Semaphore(max_concurrency)
|
|
|
|
|
|
return contextlib.nullcontext()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _as_float(value: object, default: float = 0.0) -> float:
|
|
|
|
|
|
"""Coerce loosely-typed metric dict values to ``float`` for mypy.
|
|
|
|
|
|
|
|
|
|
|
|
Strings are parsed rather than dropped: JSON-sourced session metrics such as
|
|
|
|
|
|
``audio_duration_ms`` may arrive quoted, and silently reporting ``0.0`` would
|
|
|
|
|
|
corrupt the derived TTFT / RTF numbers.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if isinstance(value, bool) or value is None:
|
|
|
|
|
|
return default
|
|
|
|
|
|
if isinstance(value, (int, float, str)):
|
|
|
|
|
|
try:
|
|
|
|
|
|
return float(value)
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
return default
|
|
|
|
|
|
return default
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-25 23:32:58 +08:00
|
|
|
|
def _append_error(existing: str, message: str) -> str:
|
|
|
|
|
|
return f"{existing}\n{message}" if existing else message
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _finalize_omniinteract_batch(
|
|
|
|
|
|
input_requests: list[SampleRequest],
|
|
|
|
|
|
outputs: list[RequestFuncOutput],
|
|
|
|
|
|
) -> dict[str, object] | None:
|
|
|
|
|
|
rows = [
|
|
|
|
|
|
(sample, output)
|
|
|
|
|
|
for sample, output in zip(input_requests, outputs, strict=True)
|
|
|
|
|
|
if isinstance(sample, OmniInteractSampleRequest)
|
|
|
|
|
|
]
|
|
|
|
|
|
if not rows:
|
|
|
|
|
|
return None
|
|
|
|
|
|
options = rows[0][0].omniinteract_options
|
|
|
|
|
|
if not isinstance(options, OmniInteractSessionOptions):
|
|
|
|
|
|
raise RuntimeError("OmniInteract benchmark output lost its artifact options")
|
|
|
|
|
|
cases, results = [], []
|
|
|
|
|
|
artifact_errors: list[str] = []
|
|
|
|
|
|
for sample, output in rows:
|
|
|
|
|
|
case = sample.omniinteract_case
|
|
|
|
|
|
result = getattr(output, "omniinteract_case_result", None)
|
|
|
|
|
|
if case is None or not isinstance(result, OmniInteractCaseResult):
|
|
|
|
|
|
raise RuntimeError("OmniInteract benchmark output lost its dataset identity")
|
|
|
|
|
|
cases.append(case)
|
|
|
|
|
|
results.append(result)
|
|
|
|
|
|
try:
|
|
|
|
|
|
publish_deferred_case_artifacts(options.output_root, case, result)
|
|
|
|
|
|
except Exception as exc: # noqa: BLE001 - artifact backends raise heterogeneous errors
|
|
|
|
|
|
result.success = result.eligible_for_official_eval = False
|
|
|
|
|
|
if "artifact_write_failed" not in result.official_eval_ineligible_reasons:
|
|
|
|
|
|
result.official_eval_ineligible_reasons.append("artifact_write_failed")
|
|
|
|
|
|
artifact_error = f"Artifact publication failed: {exc}"
|
|
|
|
|
|
result.error = _append_error(result.error, artifact_error)
|
|
|
|
|
|
output.error = _append_error(output.error, artifact_error)
|
|
|
|
|
|
message = f"{case.subset}/{case.video_rel}: {artifact_error}"
|
|
|
|
|
|
artifact_errors.append(message)
|
|
|
|
|
|
logger.exception(message)
|
|
|
|
|
|
try:
|
|
|
|
|
|
write_failure_artifacts(options.output_root, case, result)
|
|
|
|
|
|
except Exception as recovery_exc: # noqa: BLE001 - preserve benchmark metrics after I/O failure
|
|
|
|
|
|
message = f"{case.subset}/{case.video_rel}: failure artifact publication failed: {recovery_exc}"
|
|
|
|
|
|
artifact_errors.append(message)
|
|
|
|
|
|
logger.exception(message)
|
|
|
|
|
|
try:
|
|
|
|
|
|
write_omniinteract_batch_artifacts(options.output_root, cases, results)
|
|
|
|
|
|
except Exception as exc: # noqa: BLE001 - preserve benchmark metrics after I/O failure
|
|
|
|
|
|
message = f"Batch artifact publication failed: {exc}"
|
|
|
|
|
|
artifact_errors.append(message)
|
|
|
|
|
|
logger.exception(message)
|
|
|
|
|
|
try:
|
|
|
|
|
|
clear_batch_artifacts(options.output_root)
|
|
|
|
|
|
except Exception as cleanup_exc: # noqa: BLE001 - best-effort removal of partial batch artifacts
|
|
|
|
|
|
message = f"Partial batch artifact cleanup failed: {cleanup_exc}"
|
|
|
|
|
|
artifact_errors.append(message)
|
|
|
|
|
|
logger.exception(message)
|
|
|
|
|
|
summary = omniinteract_benchmark_summary(results)
|
|
|
|
|
|
compact_summary = {key: value for key, value in summary.items() if key != "results"}
|
|
|
|
|
|
compact_summary["artifacts_complete"] = not artifact_errors
|
|
|
|
|
|
if artifact_errors:
|
|
|
|
|
|
compact_summary["artifact_errors"] = artifact_errors
|
|
|
|
|
|
return compact_summary
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-24 16:34:23 +08:00
|
|
|
|
async def _evaluate_omniinteract_batch(
|
|
|
|
|
|
input_requests: list[SampleRequest],
|
|
|
|
|
|
outputs: list[RequestFuncOutput],
|
|
|
|
|
|
) -> dict[str, object] | None:
|
|
|
|
|
|
rows = [
|
|
|
|
|
|
(sample, output)
|
|
|
|
|
|
for sample, output in zip(input_requests, outputs, strict=True)
|
|
|
|
|
|
if isinstance(sample, OmniInteractSampleRequest)
|
|
|
|
|
|
]
|
|
|
|
|
|
if not rows:
|
|
|
|
|
|
return None
|
|
|
|
|
|
options = rows[0][0].omniinteract_options
|
|
|
|
|
|
if not isinstance(options, OmniInteractSessionOptions) or options.evaluation is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
cases, results = [], []
|
|
|
|
|
|
for sample, output in rows:
|
|
|
|
|
|
result = getattr(output, "omniinteract_case_result", None)
|
|
|
|
|
|
if sample.omniinteract_case is None or not isinstance(result, OmniInteractCaseResult):
|
|
|
|
|
|
raise RuntimeError("OmniInteract benchmark output lost its dataset identity")
|
|
|
|
|
|
cases.append(sample.omniinteract_case)
|
|
|
|
|
|
results.append(result)
|
|
|
|
|
|
try:
|
|
|
|
|
|
return await asyncio.to_thread(
|
|
|
|
|
|
evaluate_omniinteract_batch,
|
|
|
|
|
|
cases,
|
|
|
|
|
|
results,
|
|
|
|
|
|
options.evaluation,
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception as exc: # noqa: BLE001 - post-hoc accuracy must not fail a finished benchmark
|
|
|
|
|
|
logger.exception("OmniInteract evaluation failed")
|
|
|
|
|
|
return {"status": "failed", "error": str(exc)}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-25 23:32:58 +08:00
|
|
|
|
def _prepare_omniinteract_batch(input_requests: list[SampleRequest]) -> None:
|
|
|
|
|
|
roots: set[Path] = set()
|
|
|
|
|
|
for sample in input_requests:
|
|
|
|
|
|
if not isinstance(sample, OmniInteractSampleRequest):
|
|
|
|
|
|
continue
|
2026-09-20 17:15:53 +08:00
|
|
|
|
options = sample.omniinteract_options
|
|
|
|
|
|
case = sample.omniinteract_case
|
|
|
|
|
|
if options is None or case is None:
|
|
|
|
|
|
raise RuntimeError("OmniInteract benchmark input lost its dataset identity")
|
|
|
|
|
|
root = options.output_root.resolve()
|
2026-08-25 23:32:58 +08:00
|
|
|
|
roots.add(root)
|
2026-09-20 17:15:53 +08:00
|
|
|
|
clear_case_artifacts(options.output_root, case)
|
2026-08-25 23:32:58 +08:00
|
|
|
|
for root in roots:
|
|
|
|
|
|
clear_batch_artifacts(root)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-15 11:07:35 +08:00
|
|
|
|
def _daily_omni_repo_from_args(args) -> str | None:
|
|
|
|
|
|
"""Resolve HuggingFace repo id for Daily-Omni from CLI args.
|
|
|
|
|
|
|
|
|
|
|
|
vLLM allows ``--dataset-path`` to be a local path while the real HF id is
|
|
|
|
|
|
passed via ``--hf-name``. Upstream ``get_samples`` for ``hf`` only matches
|
|
|
|
|
|
a fixed elif-chain and never discovers Omni's loader, so we must detect
|
|
|
|
|
|
Daily-Omni here using either field.
|
|
|
|
|
|
"""
|
|
|
|
|
|
dp = getattr(args, "dataset_path", None)
|
|
|
|
|
|
hn = getattr(args, "hf_name", None)
|
|
|
|
|
|
if dp in DailyOmniDataset.SUPPORTED_DATASET_PATHS:
|
|
|
|
|
|
return dp
|
|
|
|
|
|
if hn in DailyOmniDataset.SUPPORTED_DATASET_PATHS:
|
|
|
|
|
|
return hn
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
2026-01-30 11:41:09 +08:00
|
|
|
|
|
2026-09-20 17:15:53 +08:00
|
|
|
|
def _looks_like_hf_dataset_id(value: str) -> bool:
|
|
|
|
|
|
"""True for Hub ids such as ``org/name``; false for local paths."""
|
|
|
|
|
|
raw = value.strip()
|
|
|
|
|
|
if not raw or raw.startswith((".", "~", "/")) or "\\" in raw:
|
|
|
|
|
|
return False
|
|
|
|
|
|
parts = raw.split("/")
|
|
|
|
|
|
return len(parts) == 2 and all(part.strip() and part.strip() not in (".", "..") for part in parts)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _videomme_repo_from_args(args, *, explicit: bool = False) -> str | None:
|
|
|
|
|
|
"""Resolve a Hugging Face repo id for Video-MME from CLI args.
|
|
|
|
|
|
|
|
|
|
|
|
``--dataset-name hf`` auto-detect only recognizes the official
|
|
|
|
|
|
``lmms-eval/Video-MME`` id so a custom Hub dataset is not silently treated
|
|
|
|
|
|
as Video-MME. Explicit ``--dataset-name videomme`` accepts any ``org/name``
|
|
|
|
|
|
Hub id (for ``--videomme-repo`` / ``VLLM_VIDEOMME_REPO`` overrides) and
|
|
|
|
|
|
raises when ``--dataset-path`` is neither a local directory nor a Hub id.
|
|
|
|
|
|
"""
|
|
|
|
|
|
official = {p.lower() for p in VideoMMEDataset.SUPPORTED_DATASET_PATHS}
|
|
|
|
|
|
official.add(VIDEOMME_DEFAULT_HF_REPO.lower())
|
|
|
|
|
|
candidates: list[str] = []
|
|
|
|
|
|
for attr in ("dataset_path", "hf_name"):
|
|
|
|
|
|
val = getattr(args, attr, None)
|
|
|
|
|
|
if isinstance(val, str) and val.strip():
|
|
|
|
|
|
candidates.append(val.strip())
|
|
|
|
|
|
for raw in candidates:
|
|
|
|
|
|
if resolve_videomme_local_root(raw) is not None:
|
|
|
|
|
|
continue
|
|
|
|
|
|
if raw.lower() in official:
|
|
|
|
|
|
return raw
|
|
|
|
|
|
if explicit and _looks_like_hf_dataset_id(raw):
|
|
|
|
|
|
return raw
|
|
|
|
|
|
if explicit:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"Unsupported Video-MME --dataset-path={raw!r}. Pass an existing local "
|
|
|
|
|
|
"directory, a Hugging Face dataset id (org/name), or omit --dataset-path "
|
|
|
|
|
|
f"to use {VIDEOMME_DEFAULT_HF_REPO}."
|
|
|
|
|
|
)
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-22 23:52:01 +08:00
|
|
|
|
def get_samples(args, tokenizer, **kwargs):
|
|
|
|
|
|
"""Omni override of ``vllm.benchmarks.datasets.get_samples``.
|
|
|
|
|
|
|
|
|
|
|
|
``**kwargs`` mirrors upstream's keyword-only arguments (today
|
|
|
|
|
|
``multimodal_backends``, passed by ``vllm/benchmarks/throughput.py``) so that
|
|
|
|
|
|
any upstream caller reaching this patched replacement keeps working; they are
|
|
|
|
|
|
forwarded to the original implementation on every delegate path.
|
|
|
|
|
|
"""
|
2026-04-15 11:07:35 +08:00
|
|
|
|
# Daily-Omni: explicit dataset name, or hf + matching path/hf-name
|
|
|
|
|
|
is_daily_omni = args.dataset_name == "daily-omni" or (
|
|
|
|
|
|
args.dataset_name == "hf" and _daily_omni_repo_from_args(args) is not None
|
|
|
|
|
|
)
|
2026-09-20 17:15:53 +08:00
|
|
|
|
is_videomme = args.dataset_name == "videomme" or (
|
|
|
|
|
|
args.dataset_name == "hf" and _videomme_repo_from_args(args) is not None
|
|
|
|
|
|
)
|
2026-06-02 15:46:01 +08:00
|
|
|
|
is_seed_tts = args.dataset_name in (
|
|
|
|
|
|
"seed-tts",
|
|
|
|
|
|
"seed-tts-text",
|
|
|
|
|
|
"seed-tts-design",
|
|
|
|
|
|
"ttsd",
|
|
|
|
|
|
"sound-effect",
|
|
|
|
|
|
)
|
2026-08-25 23:32:58 +08:00
|
|
|
|
is_omniinteract = args.dataset_name == "omniinteract"
|
2026-04-15 11:07:35 +08:00
|
|
|
|
|
|
|
|
|
|
# Check if we need to handle omni-related backends/datasets
|
2026-08-11 14:53:49 +08:00
|
|
|
|
is_omni_backend = args.backend in [
|
|
|
|
|
|
"openai-chat-omni",
|
|
|
|
|
|
"openai-audio-speech",
|
|
|
|
|
|
"openai-realtime-duplex",
|
|
|
|
|
|
"daily-omni",
|
|
|
|
|
|
]
|
2026-09-20 17:15:53 +08:00
|
|
|
|
is_omni_dataset = is_daily_omni or is_videomme or is_seed_tts or is_omniinteract or args.dataset_name == "random-mm"
|
2026-04-15 11:07:35 +08:00
|
|
|
|
|
|
|
|
|
|
if not is_omni_backend and not is_omni_dataset:
|
|
|
|
|
|
# Not an omni-related request, delegate to original implementation
|
2026-09-22 23:52:01 +08:00
|
|
|
|
return get_samples_old(args, tokenizer, **kwargs)
|
2026-04-15 11:07:35 +08:00
|
|
|
|
|
2026-08-25 23:32:58 +08:00
|
|
|
|
if is_omniinteract:
|
|
|
|
|
|
dataset_path = getattr(args, "dataset_path", None)
|
|
|
|
|
|
data_root = None
|
|
|
|
|
|
dataset_repo = DEFAULT_OMNIINTERACT_REPO
|
|
|
|
|
|
if dataset_path:
|
|
|
|
|
|
candidate = Path(dataset_path).expanduser()
|
|
|
|
|
|
if candidate.exists() or candidate.is_absolute():
|
|
|
|
|
|
data_root = str(candidate)
|
|
|
|
|
|
else:
|
|
|
|
|
|
dataset_repo = str(dataset_path)
|
|
|
|
|
|
dataset = OmniInteractDataset(
|
|
|
|
|
|
data_root=data_root,
|
|
|
|
|
|
dataset_repo=dataset_repo,
|
|
|
|
|
|
subsets=tuple(getattr(args, "omniinteract_subsets")),
|
|
|
|
|
|
random_seed=args.seed,
|
|
|
|
|
|
disable_shuffle=getattr(args, "disable_shuffle", False),
|
2026-09-24 16:34:23 +08:00
|
|
|
|
scenario_tags=tuple(getattr(args, "omniinteract_scenario_tags", None) or ()),
|
|
|
|
|
|
scenario_focus=bool(getattr(args, "omniinteract_scenario_focus", False)),
|
|
|
|
|
|
video_list=getattr(args, "omniinteract_video_list", None),
|
2026-08-25 23:32:58 +08:00
|
|
|
|
)
|
2026-09-24 16:34:23 +08:00
|
|
|
|
output_root = Path(getattr(args, "omniinteract_output_dir"))
|
|
|
|
|
|
evaluation = None
|
|
|
|
|
|
if bool(getattr(args, "omniinteract_evaluate", False)):
|
|
|
|
|
|
evaluation_output = getattr(args, "omniinteract_eval_output_dir", None)
|
|
|
|
|
|
evaluation = OmniInteractEvaluationOptions(
|
|
|
|
|
|
judge_base_url=str(getattr(args, "omniinteract_judge_base_url", "http://127.0.0.1:8000")),
|
|
|
|
|
|
judge_model=str(getattr(args, "omniinteract_judge_model", "")),
|
|
|
|
|
|
judge_api_key=str(getattr(args, "omniinteract_judge_api_key", "EMPTY")),
|
|
|
|
|
|
judge_timeout_s=float(getattr(args, "omniinteract_judge_timeout_s", 60.0)),
|
|
|
|
|
|
judge_max_tokens=int(getattr(args, "omniinteract_judge_max_tokens", 512)),
|
|
|
|
|
|
workers=int(getattr(args, "omniinteract_eval_workers", 8)),
|
|
|
|
|
|
output_dir=Path(evaluation_output) if evaluation_output else output_root / "evaluation",
|
|
|
|
|
|
skip_existing=bool(getattr(args, "omniinteract_eval_skip_existing", False)),
|
|
|
|
|
|
)
|
2026-08-25 23:32:58 +08:00
|
|
|
|
options = OmniInteractSessionOptions(
|
2026-09-24 16:34:23 +08:00
|
|
|
|
output_root=output_root,
|
2026-08-25 23:32:58 +08:00
|
|
|
|
timeout_s=float(getattr(args, "omniinteract_timeout_s")),
|
|
|
|
|
|
media_timeout_s=float(getattr(args, "omniinteract_media_timeout_s")),
|
|
|
|
|
|
ref_audio=str(getattr(args, "omniinteract_ref_audio")),
|
|
|
|
|
|
require_response=bool(getattr(args, "omniinteract_require_response")),
|
|
|
|
|
|
max_video_duration_s=float(getattr(args, "omniinteract_max_video_duration_s")),
|
2026-09-24 16:34:23 +08:00
|
|
|
|
evaluation=evaluation,
|
2026-08-25 23:32:58 +08:00
|
|
|
|
)
|
|
|
|
|
|
requests = dataset.sample(
|
|
|
|
|
|
tokenizer,
|
|
|
|
|
|
args.num_prompts,
|
|
|
|
|
|
request_id_prefix=args.request_id_prefix,
|
|
|
|
|
|
options=options,
|
|
|
|
|
|
)
|
|
|
|
|
|
if not requests:
|
|
|
|
|
|
raise ValueError("No OmniInteract sessions were selected")
|
2026-09-05 16:20:21 +08:00
|
|
|
|
from vllm_omni.clients.duplex import reference_audio_data_url
|
|
|
|
|
|
|
2026-08-25 23:32:58 +08:00
|
|
|
|
encoded_ref_audio = reference_audio_data_url(options.ref_audio)
|
|
|
|
|
|
assert encoded_ref_audio is not None
|
|
|
|
|
|
for request in requests:
|
|
|
|
|
|
case = request.omniinteract_case
|
|
|
|
|
|
assert isinstance(request, OmniInteractSampleRequest) and case is not None
|
|
|
|
|
|
duration, pcm, frames = prepare_media(
|
|
|
|
|
|
case.video_path,
|
|
|
|
|
|
VIDEO_FPS,
|
|
|
|
|
|
timeout_s=options.media_timeout_s,
|
|
|
|
|
|
max_duration_s=options.max_video_duration_s,
|
|
|
|
|
|
)
|
|
|
|
|
|
if not any(frames):
|
|
|
|
|
|
raise ValueError(f"No video frames were decoded from {case.video_path}")
|
|
|
|
|
|
request.omniinteract_prepared_input = OmniInteractPreparedInput(
|
|
|
|
|
|
duration_s=duration,
|
|
|
|
|
|
pcm16=pcm,
|
|
|
|
|
|
video_frames=tuple(frames),
|
|
|
|
|
|
ref_audio_data_url=encoded_ref_audio,
|
|
|
|
|
|
)
|
|
|
|
|
|
# Replace OmniInteract's ``0`` (all) with the measured request count.
|
|
|
|
|
|
args.num_prompts = len(requests)
|
|
|
|
|
|
return requests
|
|
|
|
|
|
|
2026-04-15 11:07:35 +08:00
|
|
|
|
# Handle Daily-Omni dataset
|
|
|
|
|
|
if is_daily_omni:
|
|
|
|
|
|
# Support:
|
|
|
|
|
|
# --dataset-name daily-omni [--dataset-path liarliar/Daily-Omni]
|
|
|
|
|
|
# --dataset-name daily-omni --daily-omni-qa-json /path/to/qa.json (offline QA)
|
|
|
|
|
|
# --dataset-name hf --dataset-path liarliar/Daily-Omni
|
|
|
|
|
|
# --dataset-name hf --hf-name liarliar/Daily-Omni (dataset-path may be local)
|
|
|
|
|
|
|
|
|
|
|
|
# Validate backend supports multimodal (video)
|
|
|
|
|
|
if args.backend not in ["openai-chat-omni", "daily-omni"]:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"Daily-Omni dataset requires a multimodal backend that supports video. "
|
|
|
|
|
|
f"Got backend='{args.backend}'. Please use '--backend openai-chat-omni'"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Determine video directory if specified (for local video files)
|
|
|
|
|
|
video_dir = getattr(args, "daily_omni_video_dir", None)
|
|
|
|
|
|
|
|
|
|
|
|
# Get HF split (default to "train"; unused when loading from local qa.json)
|
|
|
|
|
|
dataset_split = getattr(args, "hf_split", None) or "train"
|
|
|
|
|
|
|
|
|
|
|
|
qa_json = getattr(args, "daily_omni_qa_json", None)
|
|
|
|
|
|
if isinstance(qa_json, str):
|
|
|
|
|
|
qa_json = qa_json.strip() or None
|
|
|
|
|
|
|
2026-08-12 18:36:31 +08:00
|
|
|
|
# A local mirror of the dataset repo (plain directory or HF hub cache dir) is used
|
|
|
|
|
|
# directly: no Hub round-trip, so offline / air-gapped runs work without extra flags.
|
|
|
|
|
|
local_root: Path | None = None
|
|
|
|
|
|
if qa_json is None:
|
|
|
|
|
|
local_root = resolve_daily_omni_local_root(getattr(args, "dataset_path", None)) or (
|
|
|
|
|
|
resolve_daily_omni_local_root(getattr(args, "hf_name", None))
|
|
|
|
|
|
)
|
|
|
|
|
|
if local_root is not None:
|
|
|
|
|
|
local_qa = daily_omni_local_qa_json(local_root)
|
|
|
|
|
|
if local_qa is not None:
|
|
|
|
|
|
qa_json = str(local_qa)
|
|
|
|
|
|
if video_dir is None:
|
|
|
|
|
|
video_dir = daily_omni_local_videos_dir(local_root)
|
|
|
|
|
|
logger.info("Using local Daily-Omni mirror: root=%s", local_root)
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
"Local Daily-Omni path %s has no qa.json; loading it with `datasets` instead",
|
|
|
|
|
|
local_root,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-15 11:07:35 +08:00
|
|
|
|
if qa_json is not None:
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
"Loading Daily-Omni dataset: qa_json=%s, video_dir=%s (Hub not used for QA)",
|
|
|
|
|
|
qa_json,
|
|
|
|
|
|
video_dir,
|
|
|
|
|
|
)
|
|
|
|
|
|
dataset = DailyOmniDataset(
|
|
|
|
|
|
qa_json_path=qa_json,
|
|
|
|
|
|
dataset_path=None,
|
|
|
|
|
|
dataset_split=dataset_split,
|
|
|
|
|
|
random_seed=args.seed,
|
|
|
|
|
|
video_dir=video_dir,
|
|
|
|
|
|
input_mode=getattr(args, "daily_omni_input_mode", "all"),
|
2026-08-01 13:41:00 +08:00
|
|
|
|
pack_mode=getattr(args, "daily_omni_pack_mode", "qwen"),
|
2026-04-15 11:07:35 +08:00
|
|
|
|
inline_local_video=getattr(args, "daily_omni_inline_local_video", False),
|
|
|
|
|
|
trust_remote_code=getattr(args, "trust_remote_code", False),
|
|
|
|
|
|
disable_shuffle=getattr(args, "disable_shuffle", False),
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
repo_id = _daily_omni_repo_from_args(args)
|
|
|
|
|
|
if args.dataset_name == "daily-omni":
|
|
|
|
|
|
if repo_id is None:
|
2026-08-12 18:36:31 +08:00
|
|
|
|
# Prefer an on-disk copy over the Hub id so offline runs keep working.
|
|
|
|
|
|
repo_id = str(local_root) if local_root is not None else _DEFAULT_DAILY_OMNI_REPO
|
2026-04-15 11:07:35 +08:00
|
|
|
|
elif repo_id is None:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
"Daily-Omni with --dataset-name hf requires "
|
|
|
|
|
|
f"--dataset-path {_DEFAULT_DAILY_OMNI_REPO} or "
|
|
|
|
|
|
f"--hf-name {_DEFAULT_DAILY_OMNI_REPO}."
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
"Loading Daily-Omni dataset: hf_repo=%s, split=%s, video_dir=%s",
|
|
|
|
|
|
repo_id,
|
|
|
|
|
|
dataset_split,
|
|
|
|
|
|
video_dir,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
dataset = DailyOmniDataset(
|
|
|
|
|
|
dataset_path=repo_id,
|
|
|
|
|
|
dataset_split=dataset_split,
|
|
|
|
|
|
dataset_subset=getattr(args, "hf_subset", None),
|
|
|
|
|
|
random_seed=args.seed,
|
|
|
|
|
|
video_dir=video_dir,
|
|
|
|
|
|
input_mode=getattr(args, "daily_omni_input_mode", "all"),
|
2026-08-01 13:41:00 +08:00
|
|
|
|
pack_mode=getattr(args, "daily_omni_pack_mode", "qwen"),
|
2026-04-15 11:07:35 +08:00
|
|
|
|
inline_local_video=getattr(args, "daily_omni_inline_local_video", False),
|
|
|
|
|
|
trust_remote_code=getattr(args, "trust_remote_code", False),
|
|
|
|
|
|
no_stream=getattr(args, "no_stream", False),
|
|
|
|
|
|
disable_shuffle=getattr(args, "disable_shuffle", False),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
out_len = getattr(args, "output_len", None)
|
|
|
|
|
|
if out_len is None:
|
|
|
|
|
|
out_len = getattr(args, "hf_output_len", None)
|
|
|
|
|
|
if out_len is None:
|
|
|
|
|
|
out_len = DailyOmniDataset.DEFAULT_OUTPUT_LEN
|
|
|
|
|
|
|
|
|
|
|
|
input_requests = dataset.sample(
|
|
|
|
|
|
tokenizer=tokenizer,
|
|
|
|
|
|
num_requests=args.num_prompts,
|
|
|
|
|
|
output_len=out_len,
|
|
|
|
|
|
request_id_prefix=args.request_id_prefix,
|
|
|
|
|
|
no_oversample=args.no_oversample,
|
|
|
|
|
|
)
|
|
|
|
|
|
return input_requests
|
|
|
|
|
|
|
2026-09-20 17:15:53 +08:00
|
|
|
|
if is_videomme:
|
|
|
|
|
|
if args.backend not in ["openai-chat-omni", "daily-omni"]:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"Video-MME dataset requires a multimodal backend that supports video. "
|
|
|
|
|
|
f"Got backend='{args.backend}'. Please use '--backend openai-chat-omni'"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Resolve the source identity here; the dataset owns loading and extraction.
|
|
|
|
|
|
local_root = resolve_videomme_local_root(getattr(args, "dataset_path", None)) or (
|
|
|
|
|
|
resolve_videomme_local_root(getattr(args, "hf_name", None))
|
|
|
|
|
|
)
|
|
|
|
|
|
source = (
|
|
|
|
|
|
str(local_root)
|
|
|
|
|
|
if local_root is not None
|
|
|
|
|
|
else (_videomme_repo_from_args(args, explicit=args.dataset_name == "videomme") or VIDEOMME_DEFAULT_HF_REPO)
|
|
|
|
|
|
)
|
|
|
|
|
|
dataset = VideoMMEDataset(
|
|
|
|
|
|
parquet_path=getattr(args, "videomme_parquet", None),
|
|
|
|
|
|
dataset_path=source,
|
|
|
|
|
|
dataset_split=getattr(args, "hf_split", None) or "test",
|
|
|
|
|
|
dataset_subset=getattr(args, "hf_subset", None),
|
|
|
|
|
|
random_seed=args.seed,
|
|
|
|
|
|
video_dir=getattr(args, "videomme_video_dir", None),
|
|
|
|
|
|
subtitle_dir=getattr(args, "videomme_subtitle_dir", None),
|
|
|
|
|
|
pack_mode=getattr(args, "videomme_pack_mode", "minicpm-frames"),
|
|
|
|
|
|
max_frames=getattr(args, "videomme_max_frames", None),
|
|
|
|
|
|
duration_filter=getattr(args, "videomme_duration", "all"),
|
|
|
|
|
|
use_subtitle=getattr(args, "videomme_use_subtitle", False),
|
|
|
|
|
|
inline_local_video=getattr(args, "videomme_inline_local_video", False),
|
|
|
|
|
|
trust_remote_code=getattr(args, "trust_remote_code", False),
|
|
|
|
|
|
no_stream=getattr(args, "no_stream", False),
|
|
|
|
|
|
disable_shuffle=getattr(args, "disable_shuffle", False),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
out_len = getattr(args, "output_len", None)
|
|
|
|
|
|
if out_len is None:
|
|
|
|
|
|
out_len = getattr(args, "hf_output_len", None)
|
|
|
|
|
|
if out_len is None:
|
|
|
|
|
|
out_len = VideoMMEDataset.DEFAULT_OUTPUT_LEN
|
|
|
|
|
|
|
|
|
|
|
|
return dataset.sample(
|
|
|
|
|
|
tokenizer=tokenizer,
|
|
|
|
|
|
num_requests=args.num_prompts,
|
|
|
|
|
|
output_len=out_len,
|
|
|
|
|
|
request_id_prefix=args.request_id_prefix,
|
|
|
|
|
|
no_oversample=args.no_oversample,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-15 11:07:35 +08:00
|
|
|
|
if is_seed_tts:
|
2026-08-11 14:53:49 +08:00
|
|
|
|
if args.backend not in (
|
|
|
|
|
|
"openai-audio-speech",
|
|
|
|
|
|
"openai-chat-omni",
|
|
|
|
|
|
"openai-realtime-duplex",
|
|
|
|
|
|
"openai-realtime-tts",
|
|
|
|
|
|
):
|
2026-04-15 11:07:35 +08:00
|
|
|
|
raise ValueError(
|
|
|
|
|
|
"Seed-TTS requires --backend openai-audio-speech (POST /v1/audio/speech) or "
|
2026-08-11 14:53:49 +08:00
|
|
|
|
"--backend openai-chat-omni (POST /v1/chat/completions with ref_audio/ref_text), or "
|
|
|
|
|
|
"--backend openai-realtime-duplex or openai-realtime-tts "
|
|
|
|
|
|
"(WebSocket /v1/realtime). "
|
2026-04-15 11:07:35 +08:00
|
|
|
|
f"Got backend={args.backend!r}."
|
|
|
|
|
|
)
|
|
|
|
|
|
repo_id = getattr(args, "dataset_path", None) or getattr(args, "hf_name", None)
|
|
|
|
|
|
if not repo_id:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
"Seed-TTS requires --dataset-path (HF dataset repo id or local directory) or "
|
|
|
|
|
|
"--hf-name for the Hub dataset id."
|
|
|
|
|
|
)
|
2026-08-11 14:53:49 +08:00
|
|
|
|
turns_per_session = int(getattr(args, "seed_tts_turns_per_session", 1))
|
|
|
|
|
|
if turns_per_session > 1 and args.backend not in {
|
|
|
|
|
|
"openai-realtime-duplex",
|
|
|
|
|
|
"openai-realtime-tts",
|
|
|
|
|
|
}:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"--seed-tts-turns-per-session > 1 requires a Realtime Seed-TTS backend. Got backend={args.backend!r}."
|
|
|
|
|
|
)
|
2026-04-15 11:07:35 +08:00
|
|
|
|
|
2026-04-24 11:35:33 -04:00
|
|
|
|
_cls_map = {
|
|
|
|
|
|
"seed-tts": SeedTTSDataset,
|
|
|
|
|
|
"seed-tts-text": SeedTTSTextDataset,
|
|
|
|
|
|
"seed-tts-design": SeedTTSDesignDataset,
|
2026-06-02 15:46:01 +08:00
|
|
|
|
"ttsd": TTSDDataset,
|
|
|
|
|
|
"sound-effect": SoundEffectDataset,
|
2026-04-24 11:35:33 -04:00
|
|
|
|
}
|
|
|
|
|
|
DatasetCls = _cls_map[args.dataset_name]
|
|
|
|
|
|
dataset = DatasetCls(
|
2026-04-15 11:07:35 +08:00
|
|
|
|
dataset_path=repo_id,
|
|
|
|
|
|
random_seed=args.seed,
|
|
|
|
|
|
locale=getattr(args, "seed_tts_locale", "en"),
|
|
|
|
|
|
inline_ref_audio=not getattr(args, "seed_tts_file_ref_audio", False),
|
|
|
|
|
|
seed_tts_root=getattr(args, "seed_tts_root", None),
|
|
|
|
|
|
system_prompt=getattr(args, "seed_tts_system_prompt", None),
|
|
|
|
|
|
disable_shuffle=getattr(args, "disable_shuffle", False),
|
|
|
|
|
|
)
|
|
|
|
|
|
out_len = getattr(args, "output_len", None)
|
|
|
|
|
|
if out_len is None:
|
|
|
|
|
|
out_len = getattr(args, "hf_output_len", None)
|
|
|
|
|
|
if out_len is None:
|
|
|
|
|
|
out_len = SeedTTSDataset.DEFAULT_OUTPUT_LEN
|
|
|
|
|
|
return dataset.sample(
|
|
|
|
|
|
tokenizer=tokenizer,
|
|
|
|
|
|
num_requests=args.num_prompts,
|
|
|
|
|
|
output_len=out_len,
|
|
|
|
|
|
request_id_prefix=args.request_id_prefix,
|
|
|
|
|
|
no_oversample=args.no_oversample,
|
2026-08-11 14:53:49 +08:00
|
|
|
|
turns_per_session=turns_per_session,
|
2026-04-15 11:07:35 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Handle random-mm dataset (Omni's synthetic multimodal dataset)
|
|
|
|
|
|
if args.dataset_name == "random-mm":
|
2026-01-30 11:41:09 +08:00
|
|
|
|
dataset = OmniRandomMultiModalDataset(random_seed=args.seed, dataset_path=args.dataset_path)
|
|
|
|
|
|
input_requests = dataset.sample(
|
|
|
|
|
|
tokenizer=tokenizer,
|
|
|
|
|
|
num_requests=args.num_prompts,
|
|
|
|
|
|
prefix_len=args.random_prefix_len,
|
|
|
|
|
|
range_ratio=args.random_range_ratio,
|
|
|
|
|
|
input_len=args.random_input_len,
|
|
|
|
|
|
output_len=args.random_output_len,
|
|
|
|
|
|
base_items_per_request=args.random_mm_base_items_per_request,
|
|
|
|
|
|
limit_mm_per_prompt=args.random_mm_limit_mm_per_prompt,
|
|
|
|
|
|
num_mm_items_range_ratio=args.random_mm_num_mm_items_range_ratio,
|
|
|
|
|
|
bucket_config=args.random_mm_bucket_config,
|
|
|
|
|
|
request_id_prefix=args.request_id_prefix,
|
|
|
|
|
|
no_oversample=args.no_oversample,
|
|
|
|
|
|
)
|
|
|
|
|
|
return input_requests
|
|
|
|
|
|
else:
|
2026-09-22 23:52:01 +08:00
|
|
|
|
return get_samples_old(args, tokenizer, **kwargs)
|
2026-01-30 11:41:09 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
datasets.get_samples = get_samples
|
|
|
|
|
|
|
2026-04-15 11:07:35 +08:00
|
|
|
|
_serve_mod = sys.modules.get("vllm.benchmarks.serve")
|
|
|
|
|
|
if _serve_mod is not None:
|
2026-09-20 17:15:53 +08:00
|
|
|
|
setattr(_serve_mod, "get_samples", get_samples)
|
2026-04-15 11:07:35 +08:00
|
|
|
|
|
2026-01-30 11:41:09 +08:00
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class MixRequestFuncOutput(RequestFuncOutput):
|
|
|
|
|
|
audio_ttfp: float = 0.0
|
|
|
|
|
|
audio_duration: float = 0.0
|
|
|
|
|
|
audio_frames: int = 0
|
|
|
|
|
|
audio_rtf: float = 0.0
|
2026-06-04 21:43:35 +08:00
|
|
|
|
image_count: int = 0
|
|
|
|
|
|
image_generation_time_ms: float = 0.0
|
|
|
|
|
|
image_pixels: int = 0
|
|
|
|
|
|
denoise_step_latency_ms: float = 0.0
|
2026-09-08 09:28:21 +08:00
|
|
|
|
video_duration: float = 0.0
|
|
|
|
|
|
video_rtf: float = 0.0
|
|
|
|
|
|
video_frames: int = 0
|
|
|
|
|
|
video_generation_time_ms: float = 0.0
|
|
|
|
|
|
peak_memory_mb: float = 0.0
|
2026-02-12 17:24:01 +08:00
|
|
|
|
text_latency: float = 0.0
|
2026-08-25 23:32:58 +08:00
|
|
|
|
tpot_measured: bool = True
|
2026-05-20 01:08:56 -04:00
|
|
|
|
#: Worst-case streaming-audio underrun (wall-clock seconds the player
|
|
|
|
|
|
#: would have been starved). Populated by the audio-speech backend; ``0.0``
|
|
|
|
|
|
#: for backends that do not run continuity analysis.
|
|
|
|
|
|
audio_underrun_s: float = 0.0
|
|
|
|
|
|
#: Whether the request stayed under the continuity threshold (default
|
|
|
|
|
|
#: 100 ms). Mirrors ``audio_underrun_s <= threshold``.
|
|
|
|
|
|
audio_continuity_ok: bool = True
|
|
|
|
|
|
#: Number of inter-chunk intervals during which the player buffer went
|
|
|
|
|
|
#: negative.
|
|
|
|
|
|
audio_underrun_event_count: int = 0
|
2026-04-15 11:07:35 +08:00
|
|
|
|
#: Raw PCM s16le mono at 24 kHz for Seed-TTS WER: from ``/v1/audio/speech`` stream or
|
|
|
|
|
|
#: resampled export after ``openai-chat-omni`` audio deltas.
|
|
|
|
|
|
tts_output_pcm_bytes: bytes | None = None
|
2026-08-11 14:53:49 +08:00
|
|
|
|
#: Per-turn 24 kHz mono PCM for grouped Realtime Seed-TTS WER.
|
|
|
|
|
|
tts_turn_pcm_bytes: list[bytes] | None = None
|
2026-06-04 21:43:35 +08:00
|
|
|
|
#: Per-stage snapshot from orchestrator ``metrics["stage_metrics"]`` (merged across SSE chunks).
|
|
|
|
|
|
stage_metrics: dict[str, dict] | None = None
|
2026-09-22 10:16:39 +08:00
|
|
|
|
#: Diffusion pipeline profiler timings from response ``stage_durations``
|
|
|
|
|
|
#: (e.g. diffuse / text_encoder.forward / vae.decode), when present.
|
|
|
|
|
|
stage_durations: dict[str, float] | None = None
|
2026-06-04 21:43:35 +08:00
|
|
|
|
stage_id: int | None = None
|
|
|
|
|
|
final_output_type: str | None = None
|
2026-08-11 14:53:49 +08:00
|
|
|
|
duplex_request_metrics: list[dict[str, object]] | None = None
|
|
|
|
|
|
duplex_session_metrics: dict[str, object] | None = None
|
2026-06-04 21:43:35 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_IMAGE_EDITS_EXTRA_BODY_FORM_FIELDS = (
|
|
|
|
|
|
"negative_prompt",
|
|
|
|
|
|
"num_inference_steps",
|
|
|
|
|
|
"guidance_scale",
|
2026-09-22 10:16:39 +08:00
|
|
|
|
"guidance_scale_2",
|
2026-06-04 21:43:35 +08:00
|
|
|
|
"strength",
|
|
|
|
|
|
"true_cfg_scale",
|
|
|
|
|
|
"seed",
|
|
|
|
|
|
"generator_device",
|
|
|
|
|
|
"lora",
|
|
|
|
|
|
"layers",
|
|
|
|
|
|
"resolution",
|
|
|
|
|
|
"bot_task",
|
|
|
|
|
|
"sys_type",
|
|
|
|
|
|
"system_prompt",
|
|
|
|
|
|
RETURN_STAGE_METRICS_FIELD,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _guess_mime_type(path: str) -> str:
|
|
|
|
|
|
mime, _ = mimetypes.guess_type(path)
|
|
|
|
|
|
return mime or "application/octet-stream"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-22 10:16:39 +08:00
|
|
|
|
_IMAGE_REFERENCE_SUFFIXES = frozenset({".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".heic", ".heif"})
|
|
|
|
|
|
_VIDEO_REFERENCE_SUFFIXES = frozenset({".mp4", ".mov", ".webm", ".mkv", ".m4v"})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _string_reference_kind(reference: str) -> str | None:
|
|
|
|
|
|
"""Classify a bare reference string as image, video, or file.
|
|
|
|
|
|
|
|
|
|
|
|
``data:image`` / ``data:video`` carry their type in the URL. Bare http(s)
|
|
|
|
|
|
URLs use the path extension. An existing local path is a file upload, not
|
|
|
|
|
|
an image or a video URL.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if reference.startswith("data:image"):
|
|
|
|
|
|
return "image"
|
|
|
|
|
|
if reference.startswith("data:video"):
|
|
|
|
|
|
return "video"
|
|
|
|
|
|
if reference.startswith(("http://", "https://")):
|
|
|
|
|
|
suffix = Path(urlparse(reference).path).suffix.lower()
|
|
|
|
|
|
if suffix in _VIDEO_REFERENCE_SUFFIXES:
|
|
|
|
|
|
return "video"
|
|
|
|
|
|
if suffix in _IMAGE_REFERENCE_SUFFIXES:
|
|
|
|
|
|
return "image"
|
|
|
|
|
|
return None
|
|
|
|
|
|
local_path = reference.removeprefix("file://")
|
|
|
|
|
|
if local_path and os.path.exists(local_path):
|
|
|
|
|
|
return "file"
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _iter_image_reference_inputs(value: Any) -> Iterable[Any]:
|
|
|
|
|
|
"""Yield image references from benchmark multimodal content.
|
|
|
|
|
|
|
|
|
|
|
|
``random-mm`` image buckets arrive as OpenAI chat parts
|
|
|
|
|
|
``{"type": "image_url", "image_url": {"url": ...}}``. Yield
|
|
|
|
|
|
``{"image_url": url}`` so the form helper keeps an explicit image type.
|
|
|
|
|
|
Bare video strings are left for ``_iter_video_reference_inputs``.
|
|
|
|
|
|
"""
|
2026-06-04 21:43:35 +08:00
|
|
|
|
if value is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
if isinstance(value, list):
|
|
|
|
|
|
for item in value:
|
2026-09-22 10:16:39 +08:00
|
|
|
|
yield from _iter_image_reference_inputs(item)
|
2026-06-04 21:43:35 +08:00
|
|
|
|
return
|
|
|
|
|
|
if not isinstance(value, dict):
|
2026-09-22 10:16:39 +08:00
|
|
|
|
if isinstance(value, str) and _string_reference_kind(value) == "video":
|
|
|
|
|
|
return
|
2026-06-04 21:43:35 +08:00
|
|
|
|
yield value
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
content_type = value.get("type")
|
|
|
|
|
|
if content_type == "image_url":
|
|
|
|
|
|
image_url = value.get("image_url")
|
|
|
|
|
|
if isinstance(image_url, dict):
|
|
|
|
|
|
url = image_url.get("url")
|
2026-09-22 10:16:39 +08:00
|
|
|
|
if isinstance(url, str) and url:
|
|
|
|
|
|
yield {"image_url": url}
|
|
|
|
|
|
elif isinstance(image_url, str) and image_url:
|
|
|
|
|
|
yield {"image_url": image_url}
|
2026-06-04 21:43:35 +08:00
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
for key in ("image", "images"):
|
|
|
|
|
|
if key in value:
|
2026-09-22 10:16:39 +08:00
|
|
|
|
yield from _iter_image_reference_inputs(value[key])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _iter_video_reference_inputs(value: Any) -> Iterable[dict[str, str]]:
|
|
|
|
|
|
"""Yield structured video references from benchmark multimodal content.
|
|
|
|
|
|
|
|
|
|
|
|
``random-mm`` video buckets arrive as OpenAI chat parts
|
|
|
|
|
|
``{"type": "video_url", "video_url": {"url": ...}}``, or as a bare
|
|
|
|
|
|
``data:video`` / video http(s) string. Yield ``{"video_url": url}`` so the
|
|
|
|
|
|
form helper keeps the video branch.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if value is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
if isinstance(value, list):
|
|
|
|
|
|
for item in value:
|
|
|
|
|
|
yield from _iter_video_reference_inputs(item)
|
|
|
|
|
|
return
|
|
|
|
|
|
if not isinstance(value, dict):
|
|
|
|
|
|
if isinstance(value, str) and _string_reference_kind(value) == "video":
|
|
|
|
|
|
yield {"video_url": value}
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
if value.get("type") == "video_url":
|
|
|
|
|
|
video_url = value.get("video_url")
|
|
|
|
|
|
if isinstance(video_url, dict):
|
|
|
|
|
|
url = video_url.get("url")
|
|
|
|
|
|
if isinstance(url, str) and url:
|
|
|
|
|
|
yield {"video_url": url}
|
|
|
|
|
|
elif isinstance(video_url, str) and video_url:
|
|
|
|
|
|
yield {"video_url": video_url}
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
for key in ("video", "videos"):
|
|
|
|
|
|
if key in value:
|
|
|
|
|
|
yield from _iter_video_reference_inputs(value[key])
|
2026-06-04 21:43:35 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _add_image_edit_input_to_form(form: aiohttp.FormData, image_input: Any) -> None:
|
|
|
|
|
|
if isinstance(image_input, dict) and "bytes" in image_input:
|
|
|
|
|
|
form.add_field(
|
|
|
|
|
|
"image",
|
|
|
|
|
|
image_input["bytes"],
|
|
|
|
|
|
filename="benchmark.png",
|
|
|
|
|
|
content_type="image/png",
|
|
|
|
|
|
)
|
|
|
|
|
|
return
|
|
|
|
|
|
|
2026-09-22 10:16:39 +08:00
|
|
|
|
if isinstance(image_input, Mapping) and _is_structured_image_reference(image_input):
|
|
|
|
|
|
image_url = image_input.get("image_url")
|
|
|
|
|
|
if isinstance(image_url, str) and image_url:
|
|
|
|
|
|
_add_image_edit_input_to_form(form, image_url)
|
|
|
|
|
|
return
|
|
|
|
|
|
|
2026-06-04 21:43:35 +08:00
|
|
|
|
if isinstance(image_input, str):
|
|
|
|
|
|
if image_input.startswith(("data:image", "http://", "https://")):
|
|
|
|
|
|
form.add_field("url", image_input)
|
|
|
|
|
|
return
|
|
|
|
|
|
local_path = image_input.removeprefix("file://")
|
|
|
|
|
|
if os.path.exists(local_path):
|
|
|
|
|
|
with open(local_path, "rb") as f:
|
|
|
|
|
|
image_bytes = f.read()
|
|
|
|
|
|
form.add_field(
|
|
|
|
|
|
"image",
|
|
|
|
|
|
image_bytes,
|
|
|
|
|
|
filename=os.path.basename(local_path),
|
|
|
|
|
|
content_type=_guess_mime_type(local_path),
|
|
|
|
|
|
)
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
raise ValueError(f"Unsupported image edit input: {type(image_input).__name__}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _add_image_edit_extra_body_to_form(form: aiohttp.FormData, extra_body: dict[str, Any]) -> None:
|
|
|
|
|
|
for key in _IMAGE_EDITS_EXTRA_BODY_FORM_FIELDS:
|
|
|
|
|
|
value = extra_body.get(key)
|
|
|
|
|
|
if value is None:
|
|
|
|
|
|
continue
|
|
|
|
|
|
if isinstance(value, (dict, list)):
|
|
|
|
|
|
form.add_field(key, json.dumps(value))
|
|
|
|
|
|
else:
|
|
|
|
|
|
form.add_field(key, str(value))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _extract_output_tokens_from_metrics(metrics: dict[str, Any]) -> int | None:
|
2026-07-23 23:55:05 +08:00
|
|
|
|
top_level_tokens = coerce_positive_int_scalar(metrics.get(defs.NUM_TOKENS_OUT))
|
2026-06-04 21:43:35 +08:00
|
|
|
|
if top_level_tokens is not None:
|
|
|
|
|
|
return top_level_tokens
|
|
|
|
|
|
|
|
|
|
|
|
stage_snapshot = metrics.get("stage_metrics")
|
|
|
|
|
|
if not isinstance(stage_snapshot, dict):
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
fallback_tokens: list[int] = []
|
|
|
|
|
|
for info in stage_snapshot.values():
|
|
|
|
|
|
if not isinstance(info, dict):
|
|
|
|
|
|
continue
|
2026-07-23 23:55:05 +08:00
|
|
|
|
num_tokens_out = coerce_positive_int_scalar(info.get(defs.NUM_TOKENS_OUT))
|
2026-06-04 21:43:35 +08:00
|
|
|
|
if num_tokens_out is None:
|
|
|
|
|
|
continue
|
|
|
|
|
|
if info.get("final_output_type") == "text" or info.get("output_unit_type") == "token":
|
|
|
|
|
|
return num_tokens_out
|
|
|
|
|
|
fallback_tokens.append(num_tokens_out)
|
|
|
|
|
|
return max(fallback_tokens, default=None)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-17 10:31:48 +08:00
|
|
|
|
def _apply_usage_to_output(output: MixRequestFuncOutput, usage: dict[str, Any]) -> int | None:
|
|
|
|
|
|
"""Apply OpenAI ``usage`` fields to the benchmark output."""
|
2026-07-23 23:55:05 +08:00
|
|
|
|
if (pt := coerce_positive_int_scalar(usage.get("prompt_tokens"))) is not None:
|
2026-06-17 10:31:48 +08:00
|
|
|
|
output.prompt_len = pt
|
2026-07-23 23:55:05 +08:00
|
|
|
|
completion_tokens = coerce_positive_int_scalar(usage.get("completion_tokens"))
|
2026-06-17 10:31:48 +08:00
|
|
|
|
if completion_tokens is not None:
|
|
|
|
|
|
output.output_tokens = max(int(output.output_tokens or 0), completion_tokens)
|
|
|
|
|
|
return completion_tokens
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _resolve_token_delta_from_usage(
|
|
|
|
|
|
completion_tokens: int | None,
|
|
|
|
|
|
completion_tokens_seen: int,
|
|
|
|
|
|
) -> tuple[int, int]:
|
|
|
|
|
|
if completion_tokens is None or completion_tokens <= completion_tokens_seen:
|
|
|
|
|
|
return 0, completion_tokens_seen
|
|
|
|
|
|
delta = completion_tokens - completion_tokens_seen
|
|
|
|
|
|
return delta, completion_tokens
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _record_text_token_stream_intervals(
|
|
|
|
|
|
output: MixRequestFuncOutput,
|
|
|
|
|
|
*,
|
|
|
|
|
|
timestamp: float,
|
|
|
|
|
|
start_time: float,
|
|
|
|
|
|
token_delta: int,
|
|
|
|
|
|
most_recent_timestamp: float,
|
|
|
|
|
|
) -> float:
|
|
|
|
|
|
"""Record TTFT/ITL for ``token_delta`` newly generated text tokens."""
|
|
|
|
|
|
if token_delta <= 0:
|
|
|
|
|
|
return most_recent_timestamp
|
|
|
|
|
|
|
|
|
|
|
|
if output.ttft == 0.0:
|
|
|
|
|
|
output.ttft = timestamp - start_time
|
|
|
|
|
|
output.text_latency = timestamp - start_time
|
|
|
|
|
|
most_recent_timestamp = timestamp
|
|
|
|
|
|
if token_delta > 1:
|
|
|
|
|
|
output.itl.extend([0.0] * (token_delta - 1))
|
|
|
|
|
|
return most_recent_timestamp
|
|
|
|
|
|
|
|
|
|
|
|
interval = max(timestamp - most_recent_timestamp, 0.0)
|
|
|
|
|
|
per_token = interval / token_delta
|
|
|
|
|
|
output.itl.extend([per_token] * token_delta)
|
|
|
|
|
|
output.text_latency = timestamp - start_time
|
|
|
|
|
|
return timestamp
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-04 21:43:35 +08:00
|
|
|
|
def _update_output_stage_metrics_from_payload(
|
|
|
|
|
|
output: MixRequestFuncOutput,
|
2026-09-08 09:28:21 +08:00
|
|
|
|
data: Mapping[str, object],
|
2026-06-04 21:43:35 +08:00
|
|
|
|
*,
|
|
|
|
|
|
update_output_tokens: bool = True,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
metrics = data.get("metrics")
|
|
|
|
|
|
if not isinstance(metrics, dict):
|
|
|
|
|
|
return
|
|
|
|
|
|
if update_output_tokens:
|
|
|
|
|
|
if (num_tokens_out := _extract_output_tokens_from_metrics(metrics)) is not None:
|
2026-06-17 10:31:48 +08:00
|
|
|
|
output.output_tokens = max(int(output.output_tokens or 0), num_tokens_out)
|
2026-06-04 21:43:35 +08:00
|
|
|
|
if isinstance(sid := metrics.get("stage_id"), int):
|
|
|
|
|
|
output.stage_id = sid
|
|
|
|
|
|
if isinstance(final_output_type := metrics.get("final_output_type"), str):
|
|
|
|
|
|
output.final_output_type = final_output_type
|
|
|
|
|
|
stage_snapshot = metrics.get("stage_metrics")
|
|
|
|
|
|
if isinstance(stage_snapshot, dict):
|
|
|
|
|
|
if output.stage_metrics is None:
|
|
|
|
|
|
output.stage_metrics = {}
|
|
|
|
|
|
output.stage_metrics.update(stage_snapshot)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-24 23:56:53 +08:00
|
|
|
|
# Per-request stage fields persisted in benchmark results. Full snapshots carry
|
|
|
|
|
|
# per-token latency lists, which would inflate every chat-omni result file.
|
|
|
|
|
|
_REQUEST_STAGE_METRIC_FIELDS = (
|
|
|
|
|
|
defs.NUM_TOKENS_OUT,
|
|
|
|
|
|
"finish_reason",
|
|
|
|
|
|
defs.AUDIO_FRAMES,
|
|
|
|
|
|
f"{defs.AUDIO_DURATION}_s",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _compact_request_stage_metrics(snapshot: object) -> dict[str, dict] | None:
|
|
|
|
|
|
"""Keep the stage fields used for workload checks; empty snapshots become None."""
|
|
|
|
|
|
if not isinstance(snapshot, dict) or not snapshot:
|
|
|
|
|
|
return None
|
|
|
|
|
|
return {
|
|
|
|
|
|
stage: {field: metrics[field] for field in _REQUEST_STAGE_METRIC_FIELDS if field in metrics}
|
|
|
|
|
|
for stage, metrics in snapshot.items()
|
|
|
|
|
|
if isinstance(metrics, dict)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-31 00:43:25 +08:00
|
|
|
|
def _apply_chat_stage0_token_timings(output: MixRequestFuncOutput) -> bool:
|
|
|
|
|
|
"""Apply native Stage 0 timings from a chat response snapshot."""
|
|
|
|
|
|
stage_metrics = output.stage_metrics
|
|
|
|
|
|
if not isinstance(stage_metrics, dict):
|
|
|
|
|
|
return False
|
|
|
|
|
|
stage0 = stage_metrics.get("0")
|
|
|
|
|
|
if not isinstance(stage0, dict):
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
output_tokens = coerce_positive_int_scalar(stage0.get(defs.NUM_TOKENS_OUT))
|
|
|
|
|
|
expected_output_tokens = coerce_positive_int_scalar(output.output_tokens)
|
|
|
|
|
|
if output_tokens is None or expected_output_tokens is None or output_tokens != expected_output_tokens:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
return _apply_stage0_token_timings(
|
|
|
|
|
|
output,
|
|
|
|
|
|
[
|
|
|
|
|
|
{
|
|
|
|
|
|
"output_token_count": output_tokens,
|
|
|
|
|
|
"itls_ms": stage0.get(defs.VLLM_ITLS_MS),
|
|
|
|
|
|
"tpot_ms": stage0.get(defs.VLLM_TPOT_MS),
|
|
|
|
|
|
}
|
|
|
|
|
|
],
|
|
|
|
|
|
expected_output_tokens=expected_output_tokens,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-08 09:28:21 +08:00
|
|
|
|
def _peak_memory_mb_from_payload(data: Mapping[str, object]) -> float:
|
|
|
|
|
|
peak_memory_mb = coerce_positive_float_scalar(data.get(defs.PEAK_MEMORY_MB))
|
|
|
|
|
|
if peak_memory_mb is not None:
|
|
|
|
|
|
return peak_memory_mb
|
|
|
|
|
|
|
|
|
|
|
|
for key in ("metrics", "usage"):
|
|
|
|
|
|
nested = data.get(key)
|
|
|
|
|
|
if isinstance(nested, dict):
|
|
|
|
|
|
peak_memory_mb = coerce_positive_float_scalar(nested.get(defs.PEAK_MEMORY_MB))
|
|
|
|
|
|
if peak_memory_mb is not None:
|
|
|
|
|
|
return peak_memory_mb
|
|
|
|
|
|
|
|
|
|
|
|
response_data = data.get("data")
|
|
|
|
|
|
if isinstance(response_data, list):
|
|
|
|
|
|
for item in response_data:
|
|
|
|
|
|
if isinstance(item, dict):
|
|
|
|
|
|
peak_memory_mb = coerce_positive_float_scalar(item.get(defs.PEAK_MEMORY_MB))
|
|
|
|
|
|
if peak_memory_mb is not None:
|
|
|
|
|
|
return peak_memory_mb
|
|
|
|
|
|
|
|
|
|
|
|
choices = data.get("choices")
|
|
|
|
|
|
if isinstance(choices, list):
|
|
|
|
|
|
for choice in choices:
|
|
|
|
|
|
if not isinstance(choice, dict):
|
|
|
|
|
|
continue
|
|
|
|
|
|
contents = []
|
|
|
|
|
|
message = choice.get("message")
|
|
|
|
|
|
if isinstance(message, dict):
|
|
|
|
|
|
contents.append(message.get("content"))
|
|
|
|
|
|
delta = choice.get("delta")
|
|
|
|
|
|
if isinstance(delta, dict):
|
|
|
|
|
|
contents.append(delta.get("content"))
|
|
|
|
|
|
for content in contents:
|
|
|
|
|
|
if isinstance(content, list):
|
|
|
|
|
|
for item in content:
|
|
|
|
|
|
if isinstance(item, dict):
|
|
|
|
|
|
peak_memory_mb = coerce_positive_float_scalar(item.get(defs.PEAK_MEMORY_MB))
|
|
|
|
|
|
if peak_memory_mb is not None:
|
|
|
|
|
|
return peak_memory_mb
|
|
|
|
|
|
return 0.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _update_output_peak_memory_from_payload(output: MixRequestFuncOutput, data: Mapping[str, object]) -> None:
|
|
|
|
|
|
peak_memory_mb = _peak_memory_mb_from_payload(data)
|
|
|
|
|
|
if peak_memory_mb > output.peak_memory_mb:
|
|
|
|
|
|
output.peak_memory_mb = peak_memory_mb
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-22 10:16:39 +08:00
|
|
|
|
def _coerce_stage_durations_dict(raw: object) -> dict[str, float] | None:
|
|
|
|
|
|
"""Normalize a stage_durations mapping to ``dict[str, float]``."""
|
|
|
|
|
|
if not isinstance(raw, dict) or not raw:
|
|
|
|
|
|
return None
|
|
|
|
|
|
coerced: dict[str, float] = {}
|
|
|
|
|
|
for key, value in raw.items():
|
|
|
|
|
|
if isinstance(value, bool) or not isinstance(value, (int, float)) or not np.isfinite(value):
|
|
|
|
|
|
continue
|
|
|
|
|
|
coerced[str(key)] = float(value)
|
|
|
|
|
|
return coerced or None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _extract_stage_durations_from_payload(data: Mapping[str, object]) -> dict[str, float] | None:
|
|
|
|
|
|
"""Pull pipeline profiler timings from video/image/chat response shapes."""
|
|
|
|
|
|
found = _coerce_stage_durations_dict(data.get("stage_durations"))
|
|
|
|
|
|
if found:
|
|
|
|
|
|
return found
|
|
|
|
|
|
|
|
|
|
|
|
metrics = data.get("metrics")
|
|
|
|
|
|
if isinstance(metrics, dict):
|
|
|
|
|
|
found = _coerce_stage_durations_dict(metrics.get("stage_durations"))
|
|
|
|
|
|
if found:
|
|
|
|
|
|
return found
|
|
|
|
|
|
|
|
|
|
|
|
response_data = data.get("data")
|
|
|
|
|
|
if isinstance(response_data, list):
|
|
|
|
|
|
for item in response_data:
|
|
|
|
|
|
if isinstance(item, dict):
|
|
|
|
|
|
found = _coerce_stage_durations_dict(item.get("stage_durations"))
|
|
|
|
|
|
if found:
|
|
|
|
|
|
return found
|
|
|
|
|
|
|
|
|
|
|
|
choices = data.get("choices")
|
|
|
|
|
|
if isinstance(choices, list):
|
|
|
|
|
|
for choice in choices:
|
|
|
|
|
|
if not isinstance(choice, dict):
|
|
|
|
|
|
continue
|
|
|
|
|
|
for message_key in ("message", "delta"):
|
|
|
|
|
|
message = choice.get(message_key)
|
|
|
|
|
|
if not isinstance(message, dict):
|
|
|
|
|
|
continue
|
|
|
|
|
|
content = message.get("content")
|
|
|
|
|
|
if isinstance(content, list):
|
|
|
|
|
|
for item in content:
|
|
|
|
|
|
if isinstance(item, dict):
|
|
|
|
|
|
found = _coerce_stage_durations_dict(item.get("stage_durations"))
|
|
|
|
|
|
if found:
|
|
|
|
|
|
return found
|
|
|
|
|
|
elif isinstance(content, dict):
|
|
|
|
|
|
found = _coerce_stage_durations_dict(content.get("stage_durations"))
|
|
|
|
|
|
if found:
|
|
|
|
|
|
return found
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _update_output_stage_durations_from_payload(output: MixRequestFuncOutput, data: Mapping[str, object]) -> None:
|
|
|
|
|
|
"""Persist the full profiler ``stage_durations`` map when the response has one."""
|
|
|
|
|
|
found = _extract_stage_durations_from_payload(data)
|
|
|
|
|
|
if found:
|
|
|
|
|
|
output.stage_durations = found
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-08 09:28:21 +08:00
|
|
|
|
def _image_metrics_from_stage_metrics(metrics: object) -> tuple[int, float, int, float]:
|
2026-06-04 21:43:35 +08:00
|
|
|
|
if not isinstance(metrics, dict):
|
|
|
|
|
|
return 0, 0.0, 0, 0.0
|
|
|
|
|
|
stage_snapshot = metrics.get("stage_metrics")
|
|
|
|
|
|
if not isinstance(stage_snapshot, dict):
|
|
|
|
|
|
return 0, 0.0, 0, 0.0
|
|
|
|
|
|
image_count = 0
|
|
|
|
|
|
image_generation_ms = 0.0
|
|
|
|
|
|
image_pixels = 0
|
|
|
|
|
|
denoise_step_latency_ms = 0.0
|
|
|
|
|
|
for info in stage_snapshot.values():
|
|
|
|
|
|
if not isinstance(info, dict):
|
|
|
|
|
|
continue
|
|
|
|
|
|
final_output_type = info.get("final_output_type")
|
|
|
|
|
|
output_unit_type = info.get("output_unit_type")
|
|
|
|
|
|
if final_output_type not in {"image", "images"} and output_unit_type != "image":
|
|
|
|
|
|
continue
|
|
|
|
|
|
image_count += int(info.get(defs.OUTPUT_UNIT_COUNT) or 0)
|
|
|
|
|
|
image_generation_ms += float(info.get(defs.STAGE_GEN_TIME_MS) or 0.0)
|
|
|
|
|
|
image_pixels += int(info.get(defs.IMAGE_PIXELS) or 0)
|
|
|
|
|
|
denoise_step_latency_ms = max(
|
|
|
|
|
|
denoise_step_latency_ms,
|
|
|
|
|
|
float(info.get(defs.DENOISE_STEP_LATENCY_MS) or 0.0),
|
|
|
|
|
|
)
|
|
|
|
|
|
return image_count, image_generation_ms, image_pixels, denoise_step_latency_ms
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-08 09:28:21 +08:00
|
|
|
|
def _image_generation_ms_from_content(content: object) -> float:
|
2026-06-04 21:43:35 +08:00
|
|
|
|
if not isinstance(content, list):
|
|
|
|
|
|
return 0.0
|
|
|
|
|
|
for item in content:
|
|
|
|
|
|
if not isinstance(item, dict):
|
|
|
|
|
|
continue
|
|
|
|
|
|
stage_durations = item.get("stage_durations")
|
|
|
|
|
|
if not isinstance(stage_durations, dict):
|
|
|
|
|
|
continue
|
|
|
|
|
|
gen_values = [
|
|
|
|
|
|
float(value)
|
|
|
|
|
|
for key, value in stage_durations.items()
|
|
|
|
|
|
if str(key).endswith("_gen_ms") and isinstance(value, (int, float))
|
|
|
|
|
|
]
|
|
|
|
|
|
if gen_values:
|
|
|
|
|
|
return max(gen_values)
|
|
|
|
|
|
return 0.0
|
2026-01-30 11:41:09 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-09-08 09:28:21 +08:00
|
|
|
|
def _image_info_from_response_data(content: object) -> tuple[int, int]:
|
|
|
|
|
|
if not isinstance(content, list):
|
|
|
|
|
|
return 0, 0
|
|
|
|
|
|
image_count = 0
|
|
|
|
|
|
total_pixels = 0
|
|
|
|
|
|
for item in content:
|
|
|
|
|
|
if not isinstance(item, dict):
|
|
|
|
|
|
continue
|
|
|
|
|
|
b64_json = item.get("b64_json")
|
|
|
|
|
|
if not isinstance(b64_json, str) or not b64_json:
|
|
|
|
|
|
continue
|
|
|
|
|
|
try:
|
|
|
|
|
|
with Image.open(io.BytesIO(base64.b64decode(b64_json, validate=True))) as img:
|
|
|
|
|
|
width, height = img.size
|
|
|
|
|
|
img.verify()
|
|
|
|
|
|
image_count += 1
|
|
|
|
|
|
total_pixels += int(width) * int(height)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
logger.debug("Failed to decode generated image payload", exc_info=True)
|
|
|
|
|
|
return image_count, total_pixels
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _apply_image_metrics_from_payload(output: MixRequestFuncOutput, data: Mapping[str, object]) -> int:
|
|
|
|
|
|
"""Populate image benchmark fields from an OpenAI-compatible image payload."""
|
|
|
|
|
|
_update_output_stage_metrics_from_payload(output, data, update_output_tokens=False)
|
|
|
|
|
|
_update_output_peak_memory_from_payload(output, data)
|
2026-09-22 10:16:39 +08:00
|
|
|
|
_update_output_stage_durations_from_payload(output, data)
|
2026-09-08 09:28:21 +08:00
|
|
|
|
|
|
|
|
|
|
payload_image_count = 0
|
|
|
|
|
|
response_data = data.get("data")
|
|
|
|
|
|
if isinstance(response_data, list):
|
|
|
|
|
|
payload_image_count, content_image_pixels = _image_info_from_response_data(response_data)
|
|
|
|
|
|
output.image_count = max(output.image_count, payload_image_count)
|
|
|
|
|
|
content_image_ms = _image_generation_ms_from_content(response_data)
|
|
|
|
|
|
if content_image_ms > 0:
|
|
|
|
|
|
output.image_generation_time_ms = max(output.image_generation_time_ms, content_image_ms)
|
|
|
|
|
|
if content_image_pixels > 0:
|
|
|
|
|
|
output.image_pixels = max(output.image_pixels, content_image_pixels)
|
|
|
|
|
|
|
|
|
|
|
|
(
|
|
|
|
|
|
metrics_image_count,
|
|
|
|
|
|
metrics_image_ms,
|
|
|
|
|
|
metrics_image_pixels,
|
|
|
|
|
|
metrics_denoise_step_ms,
|
|
|
|
|
|
) = _image_metrics_from_stage_metrics(data.get("metrics"))
|
|
|
|
|
|
if metrics_image_count > output.image_count:
|
|
|
|
|
|
output.image_count = metrics_image_count
|
|
|
|
|
|
if metrics_image_ms > output.image_generation_time_ms:
|
|
|
|
|
|
output.image_generation_time_ms = metrics_image_ms
|
|
|
|
|
|
if metrics_image_pixels > output.image_pixels:
|
|
|
|
|
|
output.image_pixels = metrics_image_pixels
|
|
|
|
|
|
if metrics_denoise_step_ms > output.denoise_step_latency_ms:
|
|
|
|
|
|
output.denoise_step_latency_ms = metrics_denoise_step_ms
|
|
|
|
|
|
return payload_image_count
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_VIDEO_FORM_FIELDS = (
|
|
|
|
|
|
"seconds",
|
|
|
|
|
|
"num_frames",
|
|
|
|
|
|
"fps",
|
|
|
|
|
|
"num_inference_steps",
|
|
|
|
|
|
"seed",
|
|
|
|
|
|
"negative_prompt",
|
|
|
|
|
|
"guidance_scale",
|
|
|
|
|
|
"guidance_scale_2",
|
|
|
|
|
|
"boundary_ratio",
|
|
|
|
|
|
"flow_shift",
|
|
|
|
|
|
"true_cfg_scale",
|
|
|
|
|
|
"generate_sound",
|
|
|
|
|
|
"sound_duration",
|
|
|
|
|
|
"enable_frame_interpolation",
|
|
|
|
|
|
"frame_interpolation_exp",
|
|
|
|
|
|
"frame_interpolation_scale",
|
|
|
|
|
|
"frame_interpolation_model_path",
|
|
|
|
|
|
"lora",
|
|
|
|
|
|
"extra_params",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _video_generation_ms_from_stage_durations(stage_durations: object) -> float:
|
|
|
|
|
|
if not isinstance(stage_durations, dict):
|
|
|
|
|
|
return 0.0
|
|
|
|
|
|
gen_values = [
|
|
|
|
|
|
float(value)
|
|
|
|
|
|
for key, value in stage_durations.items()
|
|
|
|
|
|
if str(key).endswith("_gen_ms") and isinstance(value, (int, float))
|
|
|
|
|
|
]
|
|
|
|
|
|
return max(gen_values) if gen_values else 0.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _video_duration_from_payload(data: Mapping[str, object], request_body: Mapping[str, object]) -> float:
|
|
|
|
|
|
duration_s = coerce_positive_float_scalar(data.get("duration_s"))
|
|
|
|
|
|
if duration_s is not None and duration_s > 0:
|
|
|
|
|
|
return duration_s
|
|
|
|
|
|
|
|
|
|
|
|
num_frames = coerce_positive_float_scalar(data.get("num_frames"))
|
|
|
|
|
|
fps = coerce_positive_float_scalar(data.get("fps"))
|
|
|
|
|
|
if num_frames is not None and fps is not None and num_frames > 0 and fps > 0:
|
|
|
|
|
|
return num_frames / fps
|
|
|
|
|
|
|
|
|
|
|
|
seconds = coerce_positive_float_scalar(request_body.get("seconds"))
|
|
|
|
|
|
if seconds is not None and seconds > 0:
|
|
|
|
|
|
return seconds
|
|
|
|
|
|
|
|
|
|
|
|
num_frames = coerce_positive_float_scalar(request_body.get("num_frames"))
|
|
|
|
|
|
fps = coerce_positive_float_scalar(request_body.get("fps"))
|
|
|
|
|
|
if num_frames is not None and fps is not None and num_frames > 0 and fps > 0:
|
|
|
|
|
|
return num_frames / fps
|
|
|
|
|
|
|
|
|
|
|
|
return 0.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _video_frames_from_payload(data: Mapping[str, object], request_body: Mapping[str, object]) -> int:
|
|
|
|
|
|
for key in ("num_frames", "video_frames", "frames"):
|
|
|
|
|
|
value = data.get(key)
|
|
|
|
|
|
if isinstance(value, list):
|
|
|
|
|
|
return len(value)
|
|
|
|
|
|
num_frames = coerce_positive_int_scalar(value)
|
|
|
|
|
|
if num_frames is not None:
|
|
|
|
|
|
return num_frames
|
|
|
|
|
|
|
|
|
|
|
|
duration_s = coerce_positive_float_scalar(data.get("duration_s"))
|
|
|
|
|
|
fps = coerce_positive_float_scalar(data.get("fps"))
|
|
|
|
|
|
if duration_s is not None and fps is not None and duration_s > 0 and fps > 0:
|
|
|
|
|
|
return int(round(duration_s * fps))
|
|
|
|
|
|
|
|
|
|
|
|
num_frames = coerce_positive_int_scalar(request_body.get("num_frames"))
|
|
|
|
|
|
if num_frames is not None:
|
|
|
|
|
|
return num_frames
|
|
|
|
|
|
|
|
|
|
|
|
seconds = coerce_positive_float_scalar(request_body.get("seconds"))
|
|
|
|
|
|
fps = coerce_positive_float_scalar(request_body.get("fps"))
|
|
|
|
|
|
if seconds is not None and fps is not None and seconds > 0 and fps > 0:
|
|
|
|
|
|
return int(round(seconds * fps))
|
|
|
|
|
|
|
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _is_structured_image_reference(reference: Mapping[str, object]) -> bool:
|
2026-09-22 10:16:39 +08:00
|
|
|
|
"""True for API image_reference objects ({"image_url": "..."})."""
|
2026-09-08 09:28:21 +08:00
|
|
|
|
image_url = reference.get("image_url")
|
2026-09-22 10:16:39 +08:00
|
|
|
|
return isinstance(image_url, str) and bool(image_url)
|
2026-09-08 09:28:21 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-09-22 10:16:39 +08:00
|
|
|
|
def _is_structured_video_reference(reference: Mapping[str, object]) -> bool:
|
|
|
|
|
|
"""True for API video_reference objects ({"video_url": "..."})."""
|
|
|
|
|
|
video_url = reference.get("video_url")
|
|
|
|
|
|
return isinstance(video_url, str) and bool(video_url)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_VIDEO_REFERENCE_JSON_MAX_BYTES = 1024 * 1024
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _data_video_json_exceeds_text_limit(video_url: str) -> bool:
|
|
|
|
|
|
"""True when a data:video URL would exceed the ~1MB multipart text-part limit."""
|
|
|
|
|
|
if not video_url.startswith("data:video"):
|
|
|
|
|
|
return False
|
|
|
|
|
|
encoded = json.dumps({"video_url": video_url}).encode("utf-8")
|
|
|
|
|
|
return len(encoded) > _VIDEO_REFERENCE_JSON_MAX_BYTES
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _add_data_video_upload(form: aiohttp.FormData, video_url: str) -> bool:
|
|
|
|
|
|
"""Upload one inline video as ``input_references`` instead of a JSON text part."""
|
|
|
|
|
|
header, _, payload = video_url.partition(",")
|
|
|
|
|
|
if not payload:
|
|
|
|
|
|
raise ValueError(f"Unsupported video data URL: {video_url[:64]!r}")
|
|
|
|
|
|
try:
|
|
|
|
|
|
video_bytes = base64.b64decode(payload)
|
|
|
|
|
|
except (ValueError, TypeError) as exc:
|
|
|
|
|
|
raise ValueError("video data URL is not valid base64") from exc
|
|
|
|
|
|
mime = header[len("data:") :].split(";", 1)[0] or "video/mp4"
|
|
|
|
|
|
suffix = ".mp4" if mime.endswith("mp4") else ".bin"
|
|
|
|
|
|
form.add_field(
|
|
|
|
|
|
"input_references",
|
|
|
|
|
|
video_bytes,
|
|
|
|
|
|
filename=f"benchmark-reference{suffix}",
|
|
|
|
|
|
content_type=mime,
|
|
|
|
|
|
)
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _file_bytes_as_data_url(raw: bytes, mime: str) -> str:
|
|
|
|
|
|
encoded = base64.b64encode(raw).decode("ascii")
|
|
|
|
|
|
return f"data:{mime};base64,{encoded}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _image_reference_json_value(reference: object) -> object:
|
|
|
|
|
|
"""Turn an image file into a JSON ``image_reference`` when it must share a form.
|
|
|
|
|
|
|
|
|
|
|
|
``input_reference`` cannot be combined with ``video_reference``. Image URLs
|
|
|
|
|
|
stay URLs. Upload bytes and local image files become ``data:image`` URLs.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if isinstance(reference, Mapping) and "bytes" in reference and not _is_structured_image_reference(reference):
|
|
|
|
|
|
raw = reference["bytes"]
|
|
|
|
|
|
if not isinstance(raw, (bytes, bytearray)):
|
|
|
|
|
|
raise ValueError(f"image reference bytes must be bytes (got {type(raw).__name__}).")
|
|
|
|
|
|
content_type = reference.get("content_type", "image/png")
|
|
|
|
|
|
if not isinstance(content_type, str) or not content_type.startswith("image/"):
|
|
|
|
|
|
content_type = "image/png"
|
|
|
|
|
|
return {"image_url": _file_bytes_as_data_url(bytes(raw), content_type.split(";", 1)[0])}
|
|
|
|
|
|
if isinstance(reference, str):
|
|
|
|
|
|
kind = _string_reference_kind(reference)
|
|
|
|
|
|
if kind == "image":
|
|
|
|
|
|
return {"image_url": reference}
|
|
|
|
|
|
if kind == "file":
|
|
|
|
|
|
local_path = reference.removeprefix("file://")
|
|
|
|
|
|
mime = _guess_mime_type(local_path)
|
|
|
|
|
|
if not mime.startswith("image/"):
|
|
|
|
|
|
mime = "image/png"
|
|
|
|
|
|
with open(local_path, "rb") as handle:
|
|
|
|
|
|
return {"image_url": _file_bytes_as_data_url(handle.read(), mime)}
|
|
|
|
|
|
return reference
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _video_reference_json_value(reference: object) -> object:
|
|
|
|
|
|
"""Turn a video file into a JSON ``video_reference`` when it must share a form.
|
|
|
|
|
|
|
|
|
|
|
|
``input_reference`` cannot be combined with ``image_reference``. Video URLs
|
|
|
|
|
|
stay URLs. Local video files become ``data:video`` URLs.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if isinstance(reference, Mapping) and "bytes" in reference and not _is_structured_video_reference(reference):
|
|
|
|
|
|
raw = reference["bytes"]
|
|
|
|
|
|
if not isinstance(raw, (bytes, bytearray)):
|
|
|
|
|
|
raise ValueError(f"video reference bytes must be bytes (got {type(raw).__name__}).")
|
|
|
|
|
|
content_type = reference.get("content_type", "video/mp4")
|
|
|
|
|
|
if not isinstance(content_type, str) or not content_type.startswith("video/"):
|
|
|
|
|
|
content_type = "video/mp4"
|
|
|
|
|
|
return {"video_url": _file_bytes_as_data_url(bytes(raw), content_type.split(";", 1)[0])}
|
|
|
|
|
|
if isinstance(reference, str):
|
|
|
|
|
|
kind = _string_reference_kind(reference)
|
|
|
|
|
|
if kind == "video":
|
|
|
|
|
|
return {"video_url": reference}
|
|
|
|
|
|
if kind == "file":
|
|
|
|
|
|
local_path = reference.removeprefix("file://")
|
|
|
|
|
|
mime = _guess_mime_type(local_path)
|
|
|
|
|
|
if not mime.startswith("video/"):
|
|
|
|
|
|
mime = "video/mp4"
|
|
|
|
|
|
with open(local_path, "rb") as handle:
|
|
|
|
|
|
return {"video_url": _file_bytes_as_data_url(handle.read(), mime)}
|
|
|
|
|
|
return reference
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _add_video_reference_to_form(
|
|
|
|
|
|
form: aiohttp.FormData,
|
|
|
|
|
|
reference: object,
|
|
|
|
|
|
*,
|
|
|
|
|
|
upload_inline_video: bool = True,
|
|
|
|
|
|
) -> bool:
|
|
|
|
|
|
"""Encode one reference: image URL, video URL, or file upload.
|
|
|
|
|
|
|
|
|
|
|
|
Image URLs use ``image_reference``. Video URLs use ``video_reference``.
|
|
|
|
|
|
A lone ``data:video`` whose JSON text exceeds 1MB is uploaded as
|
|
|
|
|
|
``input_references``. Local paths and raw bytes use ``input_reference``.
|
|
|
|
|
|
``upload_inline_video`` must be false when an image is on the same form:
|
|
|
|
|
|
``input_references`` cannot be combined with ``image_reference``.
|
|
|
|
|
|
"""
|
|
|
|
|
|
candidates = reference if isinstance(reference, list) else [reference]
|
|
|
|
|
|
for item in candidates:
|
|
|
|
|
|
if isinstance(item, Mapping):
|
|
|
|
|
|
file_id = item.get("file_id")
|
|
|
|
|
|
if isinstance(file_id, str) and file_id:
|
|
|
|
|
|
raise ValueError("file_id is not supported yet")
|
2026-09-08 09:28:21 +08:00
|
|
|
|
if isinstance(reference, dict) and "bytes" in reference:
|
|
|
|
|
|
form.add_field(
|
|
|
|
|
|
"input_reference",
|
|
|
|
|
|
reference["bytes"],
|
|
|
|
|
|
filename="benchmark-reference",
|
|
|
|
|
|
content_type=reference.get("content_type", "application/octet-stream"),
|
|
|
|
|
|
)
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
if isinstance(reference, Mapping) and _is_structured_image_reference(reference):
|
|
|
|
|
|
form.add_field("image_reference", json.dumps(dict(reference)))
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
2026-09-22 10:16:39 +08:00
|
|
|
|
if isinstance(reference, Mapping) and _is_structured_video_reference(reference):
|
|
|
|
|
|
video_url = reference.get("video_url")
|
|
|
|
|
|
if upload_inline_video and isinstance(video_url, str) and _data_video_json_exceeds_text_limit(video_url):
|
|
|
|
|
|
return _add_data_video_upload(form, video_url)
|
|
|
|
|
|
form.add_field("video_reference", json.dumps(dict(reference)))
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
2026-09-08 09:28:21 +08:00
|
|
|
|
if isinstance(reference, list):
|
|
|
|
|
|
if reference and all(isinstance(item, Mapping) and _is_structured_image_reference(item) for item in reference):
|
|
|
|
|
|
form.add_field("image_reference", json.dumps([dict(item) for item in reference]))
|
|
|
|
|
|
return True
|
2026-09-22 10:16:39 +08:00
|
|
|
|
if reference and all(isinstance(item, Mapping) and _is_structured_video_reference(item) for item in reference):
|
|
|
|
|
|
form.add_field("video_reference", json.dumps([dict(item) for item in reference]))
|
|
|
|
|
|
return True
|
2026-09-08 09:28:21 +08:00
|
|
|
|
raise ValueError(
|
2026-09-22 10:16:39 +08:00
|
|
|
|
"Unsupported reference list; expected non-empty list of "
|
|
|
|
|
|
'{"image_url": "..."} or {"video_url": "..."} objects.'
|
2026-09-08 09:28:21 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if isinstance(reference, str):
|
2026-09-22 10:16:39 +08:00
|
|
|
|
kind = _string_reference_kind(reference)
|
|
|
|
|
|
if kind == "image":
|
2026-09-08 09:28:21 +08:00
|
|
|
|
form.add_field("image_reference", json.dumps({"image_url": reference}))
|
|
|
|
|
|
return True
|
2026-09-22 10:16:39 +08:00
|
|
|
|
if kind == "video":
|
|
|
|
|
|
if upload_inline_video and _data_video_json_exceeds_text_limit(reference):
|
|
|
|
|
|
return _add_data_video_upload(form, reference)
|
|
|
|
|
|
form.add_field("video_reference", json.dumps({"video_url": reference}))
|
|
|
|
|
|
return True
|
|
|
|
|
|
if kind == "file":
|
|
|
|
|
|
local_path = reference.removeprefix("file://")
|
|
|
|
|
|
with open(local_path, "rb") as handle:
|
|
|
|
|
|
reference_bytes = handle.read()
|
2026-09-08 09:28:21 +08:00
|
|
|
|
form.add_field(
|
|
|
|
|
|
"input_reference",
|
|
|
|
|
|
reference_bytes,
|
|
|
|
|
|
filename=os.path.basename(local_path),
|
|
|
|
|
|
content_type=_guess_mime_type(local_path),
|
|
|
|
|
|
)
|
|
|
|
|
|
return True
|
2026-09-22 10:16:39 +08:00
|
|
|
|
if reference.startswith(("http://", "https://")):
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
"Bare http(s) reference needs an image or video extension "
|
|
|
|
|
|
f"({', '.join(sorted(_IMAGE_REFERENCE_SUFFIXES | _VIDEO_REFERENCE_SUFFIXES))}); "
|
|
|
|
|
|
f"got {reference!r}."
|
|
|
|
|
|
)
|
|
|
|
|
|
raise ValueError(f"Unsupported reference path or URL: {reference!r}")
|
2026-09-08 09:28:21 +08:00
|
|
|
|
|
|
|
|
|
|
raise ValueError(
|
2026-09-22 10:16:39 +08:00
|
|
|
|
"Unsupported reference; expected image URL, video URL, upload bytes, or a local file "
|
2026-09-08 09:28:21 +08:00
|
|
|
|
f"(got {type(reference).__name__})."
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-22 10:16:39 +08:00
|
|
|
|
def _add_combined_video_form_references(
|
|
|
|
|
|
form: aiohttp.FormData,
|
|
|
|
|
|
multi_modal_content: Any,
|
|
|
|
|
|
extra_body: Mapping[str, Any] | None = None,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""Serialize image and video refs using a server-accepted field pair.
|
|
|
|
|
|
|
|
|
|
|
|
Alone, each reference uses its own field: image URL → ``image_reference``,
|
|
|
|
|
|
video URL → ``video_reference``, file → ``input_reference``. A lone inline
|
|
|
|
|
|
video whose JSON text exceeds 1MB is uploaded as ``input_references``.
|
|
|
|
|
|
Together, that upload cannot be combined with ``image_reference``, so both
|
|
|
|
|
|
sides stay on the JSON fields. A file paired with the other media is rewritten
|
|
|
|
|
|
as a data URL of the matching type.
|
|
|
|
|
|
"""
|
|
|
|
|
|
extra_body = extra_body or {}
|
|
|
|
|
|
image_refs = list(_iter_image_reference_inputs(multi_modal_content))
|
|
|
|
|
|
video_refs = list(_iter_video_reference_inputs(multi_modal_content))
|
|
|
|
|
|
if not image_refs and extra_body.get("image_reference") is not None:
|
|
|
|
|
|
image_refs = [extra_body["image_reference"]]
|
|
|
|
|
|
if not video_refs and extra_body.get("video_reference") is not None:
|
|
|
|
|
|
video_refs = [extra_body["video_reference"]]
|
|
|
|
|
|
|
|
|
|
|
|
if image_refs and video_refs:
|
|
|
|
|
|
for raw in (image_refs[0], video_refs[0]):
|
|
|
|
|
|
candidates = raw if isinstance(raw, list) else [raw]
|
|
|
|
|
|
for item in candidates:
|
|
|
|
|
|
if isinstance(item, Mapping):
|
|
|
|
|
|
file_id = item.get("file_id")
|
|
|
|
|
|
if isinstance(file_id, str) and file_id:
|
|
|
|
|
|
raise ValueError("file_id is not supported yet")
|
|
|
|
|
|
_add_video_reference_to_form(form, _image_reference_json_value(image_refs[0]))
|
|
|
|
|
|
_add_video_reference_to_form(
|
|
|
|
|
|
form,
|
|
|
|
|
|
_video_reference_json_value(video_refs[0]),
|
|
|
|
|
|
upload_inline_video=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
if image_refs:
|
|
|
|
|
|
_add_video_reference_to_form(form, image_refs[0])
|
|
|
|
|
|
if video_refs:
|
|
|
|
|
|
_add_video_reference_to_form(form, video_refs[0])
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-08 09:28:21 +08:00
|
|
|
|
def _add_video_extra_body_to_form(
|
|
|
|
|
|
form: aiohttp.FormData,
|
|
|
|
|
|
extra_body: Mapping[str, object],
|
|
|
|
|
|
request_body: Mapping[str, object],
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
for key in _VIDEO_FORM_FIELDS:
|
|
|
|
|
|
value = request_body.get(key)
|
|
|
|
|
|
if value is None:
|
|
|
|
|
|
continue
|
|
|
|
|
|
if isinstance(value, (dict, list)):
|
|
|
|
|
|
form.add_field(key, json.dumps(value))
|
|
|
|
|
|
else:
|
|
|
|
|
|
form.add_field(key, str(value))
|
|
|
|
|
|
|
|
|
|
|
|
reserved = {
|
|
|
|
|
|
"model",
|
|
|
|
|
|
"prompt",
|
|
|
|
|
|
"size",
|
|
|
|
|
|
"width",
|
|
|
|
|
|
"height",
|
|
|
|
|
|
"poll_interval_s",
|
|
|
|
|
|
"poll_timeout_s",
|
2026-09-22 10:16:39 +08:00
|
|
|
|
# Handled only by _add_video_reference_to_form (upload / JSON image_url / video_url).
|
2026-09-08 09:28:21 +08:00
|
|
|
|
"image_reference",
|
2026-09-22 10:16:39 +08:00
|
|
|
|
"video_reference",
|
2026-09-08 09:28:21 +08:00
|
|
|
|
"input_reference",
|
2026-09-22 10:16:39 +08:00
|
|
|
|
"input_references",
|
2026-09-08 09:28:21 +08:00
|
|
|
|
*_VIDEO_FORM_FIELDS,
|
|
|
|
|
|
}
|
|
|
|
|
|
for key, value in extra_body.items():
|
|
|
|
|
|
if key in reserved or value is None:
|
|
|
|
|
|
continue
|
|
|
|
|
|
if isinstance(value, (dict, list)):
|
|
|
|
|
|
form.add_field(key, json.dumps(value))
|
|
|
|
|
|
else:
|
|
|
|
|
|
form.add_field(key, str(value))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _apply_video_metrics_from_payload(
|
|
|
|
|
|
output: MixRequestFuncOutput,
|
|
|
|
|
|
data: Mapping[str, object],
|
|
|
|
|
|
request_body: Mapping[str, object],
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
output.video_duration = _video_duration_from_payload(data, request_body)
|
|
|
|
|
|
output.video_frames = _video_frames_from_payload(data, request_body)
|
|
|
|
|
|
_update_output_stage_metrics_from_payload(output, data, update_output_tokens=False)
|
|
|
|
|
|
_update_output_peak_memory_from_payload(output, data)
|
2026-09-22 10:16:39 +08:00
|
|
|
|
_update_output_stage_durations_from_payload(output, data)
|
2026-09-08 09:28:21 +08:00
|
|
|
|
|
2026-09-22 10:16:39 +08:00
|
|
|
|
stage_durations = output.stage_durations if output.stage_durations is not None else data.get("stage_durations")
|
2026-09-08 09:28:21 +08:00
|
|
|
|
stage_gen_ms = _video_generation_ms_from_stage_durations(stage_durations)
|
|
|
|
|
|
if stage_gen_ms <= 0:
|
|
|
|
|
|
inference_time_s = coerce_positive_float_scalar(data.get("inference_time_s"))
|
|
|
|
|
|
if inference_time_s is not None and inference_time_s > 0:
|
|
|
|
|
|
stage_gen_ms = inference_time_s * 1000.0
|
|
|
|
|
|
output.video_generation_time_ms = max(output.video_generation_time_ms, stage_gen_ms)
|
|
|
|
|
|
if output.video_duration <= 0:
|
|
|
|
|
|
return
|
|
|
|
|
|
# Prefer server-reported generation time so RTF is independent of client
|
|
|
|
|
|
# poll_interval_s sleep/overshoot baked into output.latency.
|
|
|
|
|
|
generation_s = output.video_generation_time_ms / 1000.0
|
|
|
|
|
|
if generation_s > 0:
|
|
|
|
|
|
output.video_rtf = generation_s / output.video_duration
|
|
|
|
|
|
elif output.latency > 0:
|
|
|
|
|
|
output.video_rtf = output.latency / output.video_duration
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-01-30 11:41:09 +08:00
|
|
|
|
async def async_request_openai_chat_omni_completions(
|
|
|
|
|
|
request_func_input: RequestFuncInput,
|
|
|
|
|
|
session: aiohttp.ClientSession,
|
|
|
|
|
|
pbar: tqdm | None = None,
|
|
|
|
|
|
mm_position: Literal["first", "last"] = "last",
|
|
|
|
|
|
) -> MixRequestFuncOutput:
|
|
|
|
|
|
api_url = request_func_input.api_url
|
|
|
|
|
|
_validate_api_url(api_url, "OpenAI Chat Completions API", "chat/completions")
|
|
|
|
|
|
|
2026-04-15 11:07:35 +08:00
|
|
|
|
omni_messages = getattr(request_func_input, "omni_chat_messages", None)
|
|
|
|
|
|
if omni_messages is not None:
|
|
|
|
|
|
messages_payload = omni_messages
|
|
|
|
|
|
else:
|
|
|
|
|
|
effective_mm_position = getattr(request_func_input, "mm_position", mm_position)
|
|
|
|
|
|
content = _get_chat_content(request_func_input, mm_position=effective_mm_position)
|
|
|
|
|
|
messages_payload = [{"role": "user", "content": content}]
|
2026-01-30 11:41:09 +08:00
|
|
|
|
|
|
|
|
|
|
payload = {
|
|
|
|
|
|
"model": request_func_input.model_name if request_func_input.model_name else request_func_input.model,
|
2026-04-15 11:07:35 +08:00
|
|
|
|
"messages": messages_payload,
|
2026-01-30 11:41:09 +08:00
|
|
|
|
"temperature": 0.0,
|
|
|
|
|
|
"max_tokens": request_func_input.output_len,
|
|
|
|
|
|
"stream": True,
|
|
|
|
|
|
"stream_options": {
|
|
|
|
|
|
"include_usage": True,
|
2026-06-17 10:31:48 +08:00
|
|
|
|
# Per-chunk completion_tokens lets _resolve_token_delta_from_usage
|
|
|
|
|
|
# compute the exact token count for each SSE flush. Without this,
|
|
|
|
|
|
# one SSE chunk can carry multiple tokens (asyncio coalescing), so
|
|
|
|
|
|
# len(itl)+1 < actual_tokens and ITL measures per-chunk latency
|
|
|
|
|
|
# (~1.74× tokens_per_chunk) rather than true per-token latency.
|
|
|
|
|
|
# NOTE: the vLLM StreamOptions field is "continuous_usage_stats",
|
|
|
|
|
|
# NOT "include_continuous_usage".
|
|
|
|
|
|
"continuous_usage_stats": True,
|
2026-01-30 11:41:09 +08:00
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
_update_payload_common(payload, request_func_input)
|
2026-04-15 11:07:35 +08:00
|
|
|
|
# Seed-TTS via chat: voice-clone fields live on the body; ensure audio is streamed.
|
|
|
|
|
|
if getattr(request_func_input, "seed_tts_row", False):
|
|
|
|
|
|
if payload.get("modalities") is None:
|
|
|
|
|
|
payload["modalities"] = ["text", "audio"]
|
2026-01-30 11:41:09 +08:00
|
|
|
|
|
2026-02-27 01:31:52 -05:00
|
|
|
|
response_format = payload.get("response_format", "wav")
|
|
|
|
|
|
if response_format == "pcm":
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
"pcm response format is not supported yet. \
|
|
|
|
|
|
Please use other formats like wav, mp3, etc. instead."
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-01-30 11:41:09 +08:00
|
|
|
|
headers = {
|
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
|
"Authorization": f"Bearer {os.environ.get('OPENAI_API_KEY')}",
|
|
|
|
|
|
}
|
|
|
|
|
|
_update_headers_common(headers, request_func_input)
|
|
|
|
|
|
|
|
|
|
|
|
output = MixRequestFuncOutput()
|
|
|
|
|
|
output.prompt_len = request_func_input.prompt_len
|
2026-03-21 11:41:02 +08:00
|
|
|
|
max_retries = 3
|
|
|
|
|
|
retry_delay = 0.1
|
|
|
|
|
|
for attempt in range(max_retries + 1):
|
|
|
|
|
|
# Reset per-attempt state so that retries do not mix partial
|
|
|
|
|
|
# outputs or metrics from previous attempts.
|
|
|
|
|
|
generated_text = ""
|
2026-05-12 11:56:10 +08:00
|
|
|
|
# For wav responses, accumulate decoded PCM bytes per chunk
|
2026-06-10 11:45:57 +08:00
|
|
|
|
# to avoid repeated decode/concat.
|
2026-05-12 11:56:10 +08:00
|
|
|
|
wav_pcm_buffer = bytearray()
|
|
|
|
|
|
wav_audio_params: tuple[int, int, int] | None = None
|
|
|
|
|
|
wav_inconsistent_chunk_count = 0
|
|
|
|
|
|
first_inconsistent_wav_params: tuple[int, int, int] | None = None
|
|
|
|
|
|
# For non-wav responses, accumulate encoded bytes then decode once.
|
|
|
|
|
|
audio_bytes_buffer = bytearray()
|
2026-03-21 11:41:02 +08:00
|
|
|
|
st = time.perf_counter()
|
|
|
|
|
|
output.start_time = st
|
|
|
|
|
|
most_recent_timestamp = st
|
|
|
|
|
|
timestamp = st
|
|
|
|
|
|
audio_generate_time = 0.0
|
|
|
|
|
|
output.itl = []
|
|
|
|
|
|
output.generated_text = ""
|
|
|
|
|
|
output.ttft = 0.0
|
|
|
|
|
|
output.audio_ttfp = 0.0
|
|
|
|
|
|
output.audio_duration = 0.0
|
|
|
|
|
|
output.audio_frames = 0
|
|
|
|
|
|
output.audio_rtf = 0.0
|
|
|
|
|
|
output.text_latency = 0.0
|
|
|
|
|
|
output.output_tokens = 0
|
|
|
|
|
|
output.error = ""
|
|
|
|
|
|
output.success = False
|
2026-06-04 21:43:35 +08:00
|
|
|
|
output.stage_metrics = {}
|
|
|
|
|
|
output.stage_id = None
|
|
|
|
|
|
output.final_output_type = None
|
|
|
|
|
|
output.image_count = 0
|
|
|
|
|
|
output.image_generation_time_ms = 0.0
|
|
|
|
|
|
output.image_pixels = 0
|
|
|
|
|
|
output.denoise_step_latency_ms = 0.0
|
2026-09-08 09:28:21 +08:00
|
|
|
|
output.peak_memory_mb = 0.0
|
2026-09-22 10:16:39 +08:00
|
|
|
|
output.stage_durations = None
|
2026-06-17 10:31:48 +08:00
|
|
|
|
completion_tokens_seen = 0
|
2026-09-22 21:28:18 +08:00
|
|
|
|
streaming_error_received = False
|
2026-03-21 11:41:02 +08:00
|
|
|
|
try:
|
|
|
|
|
|
async with session.post(url=api_url, json=payload, headers=headers) as response:
|
|
|
|
|
|
if response.status == 200:
|
|
|
|
|
|
handler = StreamedResponseHandler()
|
|
|
|
|
|
async for chunk_bytes in response.content.iter_any():
|
2026-04-14 06:49:57 -06:00
|
|
|
|
# NOTE: Do NOT strip() here; TCP may fragment the SSE messages,
|
|
|
|
|
|
# so stripping here can cause problems depending on how it is split.
|
|
|
|
|
|
#
|
|
|
|
|
|
# Simple example: [b'data: ', b'{json}\n\n'] <- stripping the first
|
|
|
|
|
|
# chunk will break SSE parsing because the space after 'data:' is required.
|
2026-03-21 11:41:02 +08:00
|
|
|
|
if not chunk_bytes:
|
2026-01-30 11:41:09 +08:00
|
|
|
|
continue
|
|
|
|
|
|
|
2026-03-21 11:41:02 +08:00
|
|
|
|
messages = handler.add_chunk(chunk_bytes)
|
|
|
|
|
|
for message in messages:
|
|
|
|
|
|
if type(message) is bytes:
|
|
|
|
|
|
message = message.decode("utf-8")
|
|
|
|
|
|
# NOTE: SSE comments (often used as pings) start with
|
|
|
|
|
|
# a colon. These are not JSON data payload and should
|
|
|
|
|
|
# be skipped.
|
|
|
|
|
|
if message.startswith(":"):
|
|
|
|
|
|
continue
|
2026-01-30 11:41:09 +08:00
|
|
|
|
|
2026-03-21 11:41:02 +08:00
|
|
|
|
chunk = message.removeprefix("data: ")
|
|
|
|
|
|
if chunk != "[DONE]":
|
|
|
|
|
|
timestamp = time.perf_counter()
|
|
|
|
|
|
data = json.loads(chunk)
|
2026-09-22 21:28:18 +08:00
|
|
|
|
if (streaming_error := data.get("error")) is not None:
|
|
|
|
|
|
streaming_error_received = True
|
|
|
|
|
|
if isinstance(streaming_error, dict):
|
|
|
|
|
|
output.error = str(streaming_error.get("message") or streaming_error)
|
|
|
|
|
|
else:
|
|
|
|
|
|
output.error = str(streaming_error)
|
|
|
|
|
|
continue
|
2026-06-17 10:31:48 +08:00
|
|
|
|
_update_output_stage_metrics_from_payload(output, data)
|
2026-09-08 09:28:21 +08:00
|
|
|
|
_update_output_peak_memory_from_payload(output, data)
|
2026-09-22 10:16:39 +08:00
|
|
|
|
_update_output_stage_durations_from_payload(output, data)
|
2026-06-17 10:31:48 +08:00
|
|
|
|
usage = data.get("usage")
|
|
|
|
|
|
completion_tokens = None
|
|
|
|
|
|
if isinstance(usage, dict):
|
|
|
|
|
|
completion_tokens = _apply_usage_to_output(output, usage)
|
|
|
|
|
|
|
2026-03-21 11:41:02 +08:00
|
|
|
|
if choices := data.get("choices"):
|
|
|
|
|
|
modality = data.get("modality")
|
2026-06-17 10:31:48 +08:00
|
|
|
|
choice = choices[0]
|
|
|
|
|
|
delta = choice.get("delta") or {}
|
2026-04-15 11:07:35 +08:00
|
|
|
|
content = delta.get("content")
|
|
|
|
|
|
if not content and isinstance(delta.get("audio"), dict):
|
|
|
|
|
|
content = delta["audio"].get("data")
|
2026-03-21 11:41:02 +08:00
|
|
|
|
if modality == "text":
|
2026-06-17 10:31:48 +08:00
|
|
|
|
token_delta, completion_tokens_seen = _resolve_token_delta_from_usage(
|
|
|
|
|
|
completion_tokens,
|
|
|
|
|
|
completion_tokens_seen,
|
|
|
|
|
|
)
|
|
|
|
|
|
token_ids = choice.get("token_ids")
|
|
|
|
|
|
if token_delta == 0 and token_ids:
|
|
|
|
|
|
token_delta = len(token_ids)
|
|
|
|
|
|
if completion_tokens is not None:
|
|
|
|
|
|
completion_tokens_seen = max(
|
|
|
|
|
|
completion_tokens_seen,
|
|
|
|
|
|
completion_tokens,
|
|
|
|
|
|
)
|
|
|
|
|
|
has_text_content = bool(content)
|
|
|
|
|
|
if token_delta == 0 and has_text_content and completion_tokens is None:
|
|
|
|
|
|
token_delta = 1
|
|
|
|
|
|
if token_delta > 0:
|
|
|
|
|
|
most_recent_timestamp = _record_text_token_stream_intervals(
|
|
|
|
|
|
output,
|
|
|
|
|
|
timestamp=timestamp,
|
|
|
|
|
|
start_time=st,
|
|
|
|
|
|
token_delta=token_delta,
|
|
|
|
|
|
most_recent_timestamp=most_recent_timestamp,
|
|
|
|
|
|
)
|
|
|
|
|
|
if has_text_content:
|
|
|
|
|
|
generated_text += content
|
2026-03-21 11:41:02 +08:00
|
|
|
|
elif modality == "audio":
|
|
|
|
|
|
if output.audio_ttfp == 0.0:
|
|
|
|
|
|
output.audio_ttfp = timestamp - st
|
|
|
|
|
|
audio_generate_time = timestamp - st
|
2026-04-15 11:07:35 +08:00
|
|
|
|
if content:
|
2026-03-21 11:41:02 +08:00
|
|
|
|
audio_bytes = base64.b64decode(content)
|
2026-05-12 11:56:10 +08:00
|
|
|
|
if response_format == "wav":
|
|
|
|
|
|
try:
|
|
|
|
|
|
with wave.open(io.BytesIO(audio_bytes), "rb") as wav_reader:
|
|
|
|
|
|
params = (
|
|
|
|
|
|
wav_reader.getnchannels(),
|
|
|
|
|
|
wav_reader.getsampwidth(),
|
|
|
|
|
|
wav_reader.getframerate(),
|
|
|
|
|
|
)
|
|
|
|
|
|
if wav_audio_params is None:
|
|
|
|
|
|
wav_audio_params = params
|
|
|
|
|
|
elif wav_audio_params != params:
|
|
|
|
|
|
wav_inconsistent_chunk_count += 1
|
|
|
|
|
|
if first_inconsistent_wav_params is None:
|
|
|
|
|
|
first_inconsistent_wav_params = params
|
|
|
|
|
|
continue
|
|
|
|
|
|
wav_pcm_buffer.extend(
|
|
|
|
|
|
wav_reader.readframes(wav_reader.getnframes())
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception as ex:
|
|
|
|
|
|
logger.warning("Failed to parse wav audio chunk: %s", ex)
|
|
|
|
|
|
else:
|
|
|
|
|
|
audio_bytes_buffer.extend(audio_bytes)
|
2026-06-04 21:43:35 +08:00
|
|
|
|
elif modality == "image":
|
|
|
|
|
|
output.image_count += 1
|
|
|
|
|
|
content_image_ms = _image_generation_ms_from_content(content)
|
|
|
|
|
|
if content_image_ms > 0:
|
|
|
|
|
|
output.image_generation_time_ms += content_image_ms
|
2026-01-30 11:41:09 +08:00
|
|
|
|
|
2026-06-04 21:43:35 +08:00
|
|
|
|
(
|
|
|
|
|
|
metrics_image_count,
|
|
|
|
|
|
metrics_image_ms,
|
|
|
|
|
|
metrics_image_pixels,
|
|
|
|
|
|
metrics_denoise_step_ms,
|
|
|
|
|
|
) = _image_metrics_from_stage_metrics(data.get("metrics"))
|
|
|
|
|
|
if metrics_image_count > output.image_count:
|
|
|
|
|
|
output.image_count = metrics_image_count
|
|
|
|
|
|
if metrics_image_ms > output.image_generation_time_ms:
|
|
|
|
|
|
output.image_generation_time_ms = metrics_image_ms
|
|
|
|
|
|
if metrics_image_pixels > output.image_pixels:
|
|
|
|
|
|
output.image_pixels = metrics_image_pixels
|
|
|
|
|
|
if metrics_denoise_step_ms > output.denoise_step_latency_ms:
|
|
|
|
|
|
output.denoise_step_latency_ms = metrics_denoise_step_ms
|
2026-03-21 11:41:02 +08:00
|
|
|
|
|
2026-05-12 11:56:10 +08:00
|
|
|
|
if wav_inconsistent_chunk_count > 0:
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
"Dropped %d wav chunks with inconsistent params during benchmark "
|
|
|
|
|
|
"(expected=%s, first_inconsistent=%s). "
|
|
|
|
|
|
"Audio frames/duration may be undercounted.",
|
|
|
|
|
|
wav_inconsistent_chunk_count,
|
|
|
|
|
|
wav_audio_params,
|
|
|
|
|
|
first_inconsistent_wav_params,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-21 11:41:02 +08:00
|
|
|
|
output.latency = timestamp - st
|
|
|
|
|
|
output.generated_text = generated_text
|
2026-08-31 00:43:25 +08:00
|
|
|
|
if output.output_tokens > 1 and not any(
|
|
|
|
|
|
isinstance(value, int | float)
|
|
|
|
|
|
and not isinstance(value, bool)
|
|
|
|
|
|
and np.isfinite(value)
|
|
|
|
|
|
and value > 0
|
|
|
|
|
|
for value in output.itl
|
|
|
|
|
|
):
|
|
|
|
|
|
_apply_chat_stage0_token_timings(output)
|
2026-06-17 10:31:48 +08:00
|
|
|
|
if output.itl:
|
|
|
|
|
|
# Align text_latency with ITL so TPOT formula and
|
|
|
|
|
|
# mean(ITL) are consistent. Do NOT infer output_tokens
|
|
|
|
|
|
# from len(itl)+1: one SSE chunk may carry multiple
|
|
|
|
|
|
# tokens, so the ITL count understates the real count.
|
|
|
|
|
|
output.text_latency = output.ttft + sum(output.itl)
|
2026-06-10 11:45:57 +08:00
|
|
|
|
audio_duration_sec = 0.0
|
|
|
|
|
|
audio_frames = 0
|
2026-05-12 11:56:10 +08:00
|
|
|
|
if response_format == "wav" and wav_pcm_buffer and wav_audio_params is not None:
|
|
|
|
|
|
channels, sample_width, frame_rate = wav_audio_params
|
2026-07-23 23:55:05 +08:00
|
|
|
|
audio_frames = defs.compute_audio_frames(
|
|
|
|
|
|
len(wav_pcm_buffer),
|
|
|
|
|
|
sample_width=sample_width,
|
|
|
|
|
|
channels=channels,
|
|
|
|
|
|
)
|
|
|
|
|
|
if audio_frames > 0 and frame_rate > 0:
|
|
|
|
|
|
audio_duration_sec = audio_frames / float(frame_rate)
|
2026-06-10 11:45:57 +08:00
|
|
|
|
else:
|
2026-07-23 23:55:05 +08:00
|
|
|
|
logger.warning(
|
|
|
|
|
|
"Unable to derive audio frames/duration from wav pcm "
|
|
|
|
|
|
"(pcm_nbytes=%d, sample_width=%d, channels=%d, sample_rate=%d)",
|
|
|
|
|
|
len(wav_pcm_buffer),
|
|
|
|
|
|
sample_width,
|
|
|
|
|
|
channels,
|
|
|
|
|
|
frame_rate,
|
|
|
|
|
|
)
|
2026-05-12 11:56:10 +08:00
|
|
|
|
elif audio_bytes_buffer:
|
|
|
|
|
|
try:
|
2026-06-10 11:45:57 +08:00
|
|
|
|
from vllm.multimodal.audio import get_audio_duration
|
|
|
|
|
|
from vllm.multimodal.media.audio import load_audio
|
|
|
|
|
|
|
|
|
|
|
|
waveform, sr = load_audio(
|
2026-05-12 11:56:10 +08:00
|
|
|
|
io.BytesIO(bytes(audio_bytes_buffer)),
|
2026-06-10 11:45:57 +08:00
|
|
|
|
sr=None,
|
|
|
|
|
|
mono=False,
|
2026-05-12 11:56:10 +08:00
|
|
|
|
)
|
2026-06-10 11:45:57 +08:00
|
|
|
|
audio_duration_sec = get_audio_duration(y=waveform, sr=sr)
|
|
|
|
|
|
audio_frames = int(audio_duration_sec * sr)
|
2026-05-12 11:56:10 +08:00
|
|
|
|
except Exception as ex:
|
|
|
|
|
|
logger.warning("Failed to decode accumulated audio bytes: %s", ex)
|
2026-06-10 11:45:57 +08:00
|
|
|
|
if audio_duration_sec > 0 or audio_frames > 0:
|
|
|
|
|
|
output.audio_duration = audio_duration_sec
|
|
|
|
|
|
output.audio_frames = audio_frames
|
2026-03-21 11:41:02 +08:00
|
|
|
|
audio_duration = output.audio_duration
|
2026-07-23 23:55:05 +08:00
|
|
|
|
output.audio_rtf = defs.compute_audio_rtf(audio_generate_time, audio_duration)
|
|
|
|
|
|
if audio_duration <= 0:
|
2026-03-21 11:41:02 +08:00
|
|
|
|
logger.warning("Audio duration is zero")
|
2026-04-15 11:07:35 +08:00
|
|
|
|
if _seed_tts_capture_pcm_for_wer() and getattr(request_func_input, "seed_tts_row", False):
|
|
|
|
|
|
try:
|
2026-06-10 11:45:57 +08:00
|
|
|
|
if response_format == "wav" and wav_pcm_buffer and wav_audio_params is not None:
|
|
|
|
|
|
from vllm.multimodal.audio import AudioResampler
|
|
|
|
|
|
|
|
|
|
|
|
pcm_channels, pcm_sw, pcm_rate = wav_audio_params
|
|
|
|
|
|
pcm = np.frombuffer(
|
|
|
|
|
|
bytes(wav_pcm_buffer), dtype=np.int16 if pcm_sw == 2 else np.float32
|
|
|
|
|
|
)
|
|
|
|
|
|
if pcm_channels > 1:
|
|
|
|
|
|
pcm = pcm.reshape(-1, pcm_channels).mean(axis=1).astype(pcm.dtype)
|
|
|
|
|
|
pcm_f32 = pcm.astype(np.float32) / 32767.0 if pcm.dtype == np.int16 else pcm
|
|
|
|
|
|
if pcm_rate != 24000:
|
|
|
|
|
|
resampler = AudioResampler(target_sr=24000)
|
|
|
|
|
|
pcm_f32 = resampler.resample(pcm_f32, orig_sr=pcm_rate)
|
|
|
|
|
|
output.tts_output_pcm_bytes = (pcm_f32 * 32767).astype(np.int16).tobytes()
|
|
|
|
|
|
elif audio_bytes_buffer:
|
|
|
|
|
|
from vllm.multimodal.media.audio import load_audio
|
|
|
|
|
|
|
|
|
|
|
|
waveform, _ = load_audio(
|
|
|
|
|
|
io.BytesIO(bytes(audio_bytes_buffer)),
|
|
|
|
|
|
sr=24000,
|
|
|
|
|
|
mono=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
output.tts_output_pcm_bytes = (waveform * 32767).astype(np.int16).tobytes()
|
2026-04-15 11:07:35 +08:00
|
|
|
|
except Exception as ex:
|
|
|
|
|
|
logger.warning("seed_tts WER PCM export failed: %s", ex)
|
2026-09-22 21:28:18 +08:00
|
|
|
|
output.success = not streaming_error_received
|
2026-03-21 11:41:02 +08:00
|
|
|
|
else:
|
|
|
|
|
|
output.error = response.reason or ""
|
|
|
|
|
|
output.success = False
|
|
|
|
|
|
break
|
|
|
|
|
|
except aiohttp.ClientError as e:
|
|
|
|
|
|
# transient transport error: may retry
|
|
|
|
|
|
output.success = False
|
|
|
|
|
|
output.error = traceback.format_exc()
|
|
|
|
|
|
if attempt < max_retries:
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
"ClientError in omni benchmark request (will retry): attempt=%d/%d delay=%.2fs: %s",
|
|
|
|
|
|
attempt + 1,
|
|
|
|
|
|
max_retries + 1,
|
|
|
|
|
|
retry_delay,
|
|
|
|
|
|
str(e),
|
|
|
|
|
|
)
|
|
|
|
|
|
await asyncio.sleep(retry_delay)
|
|
|
|
|
|
continue
|
|
|
|
|
|
logger.error(
|
|
|
|
|
|
"ClientError in omni benchmark request (giving up):\n%s",
|
|
|
|
|
|
output.error,
|
|
|
|
|
|
)
|
|
|
|
|
|
break
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
output.success = False
|
|
|
|
|
|
output.error = traceback.format_exc()
|
|
|
|
|
|
logger.error(f"ERROR: send request failed, reason is: {output.error}")
|
|
|
|
|
|
break
|
2026-02-27 01:31:52 -05:00
|
|
|
|
|
|
|
|
|
|
if pbar:
|
|
|
|
|
|
pbar.update(1)
|
|
|
|
|
|
return output
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-08 09:28:21 +08:00
|
|
|
|
def _finalize_image_json_http_response(
|
|
|
|
|
|
output: MixRequestFuncOutput,
|
|
|
|
|
|
*,
|
|
|
|
|
|
start_time: float,
|
|
|
|
|
|
status: int,
|
|
|
|
|
|
data: Mapping[str, object] | None,
|
|
|
|
|
|
error_text: str | None,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""Set e2el after the image JSON body has been fully read and validated."""
|
|
|
|
|
|
output.latency = time.perf_counter() - start_time
|
|
|
|
|
|
if status != 200:
|
|
|
|
|
|
output.error = f"HTTP {status}: {error_text or ''}"
|
|
|
|
|
|
output.success = False
|
|
|
|
|
|
return
|
|
|
|
|
|
if not isinstance(data, Mapping):
|
|
|
|
|
|
output.error = "HTTP 200 response did not contain a JSON object"
|
|
|
|
|
|
output.success = False
|
|
|
|
|
|
return
|
|
|
|
|
|
payload_image_count = _apply_image_metrics_from_payload(output, data)
|
|
|
|
|
|
if payload_image_count <= 0:
|
|
|
|
|
|
output.error = "HTTP 200 response did not contain a valid image payload"
|
|
|
|
|
|
output.success = False
|
|
|
|
|
|
return
|
|
|
|
|
|
output.success = True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def async_request_openai_image_generations_omni(
|
|
|
|
|
|
request_func_input: RequestFuncInput,
|
|
|
|
|
|
session: aiohttp.ClientSession,
|
|
|
|
|
|
pbar: tqdm | None = None,
|
|
|
|
|
|
) -> MixRequestFuncOutput:
|
|
|
|
|
|
"""JSON request to /v1/images/generations for image generation benchmarks."""
|
|
|
|
|
|
api_url = request_func_input.api_url
|
|
|
|
|
|
_validate_api_url(api_url, "OpenAI Image Generations API", "images/generations")
|
|
|
|
|
|
|
|
|
|
|
|
extra_body = dict(request_func_input.extra_body or {})
|
|
|
|
|
|
model = request_func_input.model_name if request_func_input.model_name else request_func_input.model
|
|
|
|
|
|
output = MixRequestFuncOutput()
|
|
|
|
|
|
output.prompt_len = request_func_input.prompt_len
|
|
|
|
|
|
output.itl = []
|
|
|
|
|
|
output.stage_metrics = {}
|
|
|
|
|
|
output.output_tokens = 0
|
|
|
|
|
|
output.image_count = 0
|
|
|
|
|
|
output.image_generation_time_ms = 0.0
|
|
|
|
|
|
output.image_pixels = 0
|
|
|
|
|
|
output.denoise_step_latency_ms = 0.0
|
|
|
|
|
|
|
|
|
|
|
|
size = extra_body.get("size")
|
|
|
|
|
|
if size is None:
|
|
|
|
|
|
width, height = extra_body.get("width"), extra_body.get("height")
|
|
|
|
|
|
if width is not None and height is not None:
|
|
|
|
|
|
size = f"{width}x{height}"
|
|
|
|
|
|
|
|
|
|
|
|
payload: dict[str, object] = {
|
|
|
|
|
|
"model": model,
|
|
|
|
|
|
"prompt": request_func_input.prompt,
|
|
|
|
|
|
"n": int(extra_body.pop("n", extra_body.pop("num_outputs_per_prompt", 1)) or 1),
|
|
|
|
|
|
"response_format": "b64_json",
|
|
|
|
|
|
}
|
|
|
|
|
|
if size is not None:
|
|
|
|
|
|
payload["size"] = str(size)
|
|
|
|
|
|
|
|
|
|
|
|
for key, value in extra_body.items():
|
|
|
|
|
|
if key in {"height", "width"}:
|
|
|
|
|
|
continue
|
|
|
|
|
|
payload.setdefault(key, value)
|
|
|
|
|
|
|
|
|
|
|
|
headers = {
|
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
|
"Authorization": f"Bearer {os.environ.get('OPENAI_API_KEY')}",
|
|
|
|
|
|
}
|
|
|
|
|
|
_update_headers_common(headers, request_func_input)
|
|
|
|
|
|
|
|
|
|
|
|
st = time.perf_counter()
|
|
|
|
|
|
output.start_time = st
|
|
|
|
|
|
try:
|
|
|
|
|
|
async with session.post(url=api_url, json=payload, headers=headers) as response:
|
|
|
|
|
|
if response.status == 200:
|
|
|
|
|
|
data = await response.json()
|
|
|
|
|
|
_finalize_image_json_http_response(
|
|
|
|
|
|
output,
|
|
|
|
|
|
start_time=st,
|
|
|
|
|
|
status=response.status,
|
|
|
|
|
|
data=data if isinstance(data, Mapping) else None,
|
|
|
|
|
|
error_text=None,
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
error_text = await response.text()
|
|
|
|
|
|
_finalize_image_json_http_response(
|
|
|
|
|
|
output,
|
|
|
|
|
|
start_time=st,
|
|
|
|
|
|
status=response.status,
|
|
|
|
|
|
data=None,
|
|
|
|
|
|
error_text=error_text,
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
output.latency = time.perf_counter() - st
|
|
|
|
|
|
output.success = False
|
|
|
|
|
|
output.error = traceback.format_exc()
|
|
|
|
|
|
logger.error(f"ERROR: send image generation request failed, reason is: {output.error}")
|
|
|
|
|
|
|
|
|
|
|
|
if pbar:
|
|
|
|
|
|
pbar.update(1)
|
|
|
|
|
|
return output
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def async_request_openai_videos_omni(
|
|
|
|
|
|
request_func_input: RequestFuncInput,
|
|
|
|
|
|
session: aiohttp.ClientSession,
|
|
|
|
|
|
pbar: tqdm | None = None,
|
|
|
|
|
|
) -> MixRequestFuncOutput:
|
|
|
|
|
|
"""Multipart request to async /v1/videos, polling metadata until completion."""
|
|
|
|
|
|
api_url = request_func_input.api_url
|
|
|
|
|
|
_validate_api_url(api_url, "OpenAI Videos API", "videos")
|
|
|
|
|
|
|
|
|
|
|
|
extra_body = dict(request_func_input.extra_body or {})
|
|
|
|
|
|
model = request_func_input.model_name if request_func_input.model_name else request_func_input.model
|
|
|
|
|
|
output = MixRequestFuncOutput()
|
|
|
|
|
|
output.prompt_len = request_func_input.prompt_len
|
|
|
|
|
|
output.itl = []
|
|
|
|
|
|
output.stage_metrics = {}
|
|
|
|
|
|
output.output_tokens = 0
|
|
|
|
|
|
output.video_duration = 0.0
|
|
|
|
|
|
output.video_generation_time_ms = 0.0
|
|
|
|
|
|
|
|
|
|
|
|
request_body: dict[str, object] = {
|
|
|
|
|
|
"model": model,
|
|
|
|
|
|
"prompt": request_func_input.prompt,
|
|
|
|
|
|
}
|
|
|
|
|
|
request_body.update(extra_body)
|
|
|
|
|
|
|
|
|
|
|
|
size = request_body.get("size")
|
|
|
|
|
|
if size is None:
|
|
|
|
|
|
width, height = request_body.get("width"), request_body.get("height")
|
|
|
|
|
|
if width is not None and height is not None:
|
|
|
|
|
|
size = f"{width}x{height}"
|
|
|
|
|
|
if size is not None:
|
|
|
|
|
|
request_body["size"] = str(size)
|
|
|
|
|
|
|
|
|
|
|
|
form = aiohttp.FormData()
|
|
|
|
|
|
form.add_field("model", str(model))
|
|
|
|
|
|
form.add_field("prompt", str(request_func_input.prompt))
|
|
|
|
|
|
if request_body.get("size") is not None:
|
|
|
|
|
|
form.add_field("size", str(request_body["size"]))
|
|
|
|
|
|
_add_video_extra_body_to_form(form, extra_body, request_body)
|
|
|
|
|
|
|
2026-09-22 10:16:39 +08:00
|
|
|
|
_add_combined_video_form_references(form, request_func_input.multi_modal_content, extra_body)
|
2026-09-08 09:28:21 +08:00
|
|
|
|
|
|
|
|
|
|
headers = {
|
|
|
|
|
|
"Authorization": f"Bearer {os.environ.get('OPENAI_API_KEY')}",
|
|
|
|
|
|
}
|
|
|
|
|
|
_update_headers_common(headers, request_func_input)
|
|
|
|
|
|
|
|
|
|
|
|
poll_interval_s = float(extra_body.get("poll_interval_s", 2.0) or 2.0)
|
|
|
|
|
|
timeout_s = float(extra_body.get("poll_timeout_s", 6 * 60 * 60) or (6 * 60 * 60))
|
|
|
|
|
|
st = time.perf_counter()
|
|
|
|
|
|
output.start_time = st
|
|
|
|
|
|
try:
|
|
|
|
|
|
async with session.post(url=api_url, data=form, headers=headers) as response:
|
|
|
|
|
|
if response.status != 200:
|
|
|
|
|
|
output.latency = time.perf_counter() - st
|
|
|
|
|
|
output.error = f"HTTP {response.status}: {await response.text()}"
|
|
|
|
|
|
output.success = False
|
|
|
|
|
|
return output
|
|
|
|
|
|
create_payload = await response.json()
|
|
|
|
|
|
|
|
|
|
|
|
job_id = create_payload.get("id")
|
|
|
|
|
|
job_status = create_payload.get("status")
|
|
|
|
|
|
if not isinstance(job_id, str) or not job_id:
|
|
|
|
|
|
output.latency = time.perf_counter() - st
|
|
|
|
|
|
output.error = "Video creation response missing job id."
|
|
|
|
|
|
output.success = False
|
|
|
|
|
|
return output
|
|
|
|
|
|
|
|
|
|
|
|
job_url = f"{api_url.rstrip('/')}/{job_id}"
|
|
|
|
|
|
poll_payload = create_payload
|
|
|
|
|
|
deadline = time.perf_counter() + timeout_s
|
|
|
|
|
|
while job_status not in {"completed", "failed"}:
|
|
|
|
|
|
if time.perf_counter() >= deadline:
|
|
|
|
|
|
output.latency = time.perf_counter() - st
|
|
|
|
|
|
output.error = f"Timed out waiting for video job {job_id} to complete."
|
|
|
|
|
|
output.success = False
|
|
|
|
|
|
return output
|
|
|
|
|
|
await asyncio.sleep(poll_interval_s)
|
|
|
|
|
|
async with session.get(job_url, headers=headers) as poll_response:
|
|
|
|
|
|
if poll_response.status != 200:
|
|
|
|
|
|
output.latency = time.perf_counter() - st
|
|
|
|
|
|
output.error = f"Polling failed HTTP {poll_response.status}: {await poll_response.text()}"
|
|
|
|
|
|
output.success = False
|
|
|
|
|
|
return output
|
|
|
|
|
|
poll_payload = await poll_response.json()
|
|
|
|
|
|
job_status = poll_payload.get("status")
|
|
|
|
|
|
|
|
|
|
|
|
output.latency = time.perf_counter() - st
|
|
|
|
|
|
if job_status == "failed":
|
|
|
|
|
|
output.error = f"Video job failed: {poll_payload}"
|
|
|
|
|
|
output.success = False
|
|
|
|
|
|
return output
|
|
|
|
|
|
|
|
|
|
|
|
_apply_video_metrics_from_payload(output, poll_payload, request_body)
|
|
|
|
|
|
output.success = True
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
output.latency = time.perf_counter() - st
|
|
|
|
|
|
output.success = False
|
|
|
|
|
|
output.error = traceback.format_exc()
|
|
|
|
|
|
logger.error(f"ERROR: send video request failed, reason is: {output.error}")
|
|
|
|
|
|
finally:
|
|
|
|
|
|
if pbar:
|
|
|
|
|
|
pbar.update(1)
|
|
|
|
|
|
return output
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-04 21:43:35 +08:00
|
|
|
|
async def async_request_openai_image_edits_omni(
|
|
|
|
|
|
request_func_input: RequestFuncInput,
|
|
|
|
|
|
session: aiohttp.ClientSession,
|
|
|
|
|
|
pbar: tqdm | None = None,
|
|
|
|
|
|
) -> MixRequestFuncOutput:
|
2026-09-08 09:28:21 +08:00
|
|
|
|
"""Multipart request to /v1/images/edits.
|
|
|
|
|
|
|
|
|
|
|
|
Defaults to non-streaming JSON so single-stage edit models work. The server
|
|
|
|
|
|
rejects ``stream=true`` when ``len(stage_configs) <= 1``. Pass
|
|
|
|
|
|
``stream: true`` in ``--extra-body`` for multi-stage SSE (AR TTFT / image
|
|
|
|
|
|
chunks).
|
|
|
|
|
|
"""
|
2026-06-04 21:43:35 +08:00
|
|
|
|
api_url = request_func_input.api_url
|
|
|
|
|
|
_validate_api_url(api_url, "OpenAI Image Edits API", "images/edits")
|
|
|
|
|
|
|
|
|
|
|
|
extra_body = dict(request_func_input.extra_body or {})
|
2026-09-08 09:28:21 +08:00
|
|
|
|
want_stream = coerce_bool(extra_body.pop("stream", None), default=False)
|
2026-06-04 21:43:35 +08:00
|
|
|
|
model = request_func_input.model_name if request_func_input.model_name else request_func_input.model
|
|
|
|
|
|
output = MixRequestFuncOutput()
|
|
|
|
|
|
output.prompt_len = request_func_input.prompt_len
|
|
|
|
|
|
output.itl = []
|
|
|
|
|
|
output.stage_metrics = {}
|
|
|
|
|
|
output.output_tokens = 0
|
|
|
|
|
|
output.image_count = 0
|
|
|
|
|
|
output.image_generation_time_ms = 0.0
|
|
|
|
|
|
output.image_pixels = 0
|
|
|
|
|
|
output.denoise_step_latency_ms = 0.0
|
|
|
|
|
|
|
|
|
|
|
|
form = aiohttp.FormData()
|
|
|
|
|
|
form.add_field("model", model)
|
|
|
|
|
|
form.add_field("prompt", request_func_input.prompt)
|
|
|
|
|
|
form.add_field("response_format", "b64_json")
|
|
|
|
|
|
form.add_field("output_format", str(extra_body.get("output_format", "png")))
|
2026-09-08 09:28:21 +08:00
|
|
|
|
form.add_field("stream", "true" if want_stream else "false")
|
2026-06-04 21:43:35 +08:00
|
|
|
|
|
|
|
|
|
|
size = extra_body.get("size")
|
|
|
|
|
|
if size is None:
|
|
|
|
|
|
width, height = extra_body.get("width"), extra_body.get("height")
|
|
|
|
|
|
size = f"{width}x{height}" if width is not None and height is not None else "auto"
|
|
|
|
|
|
form.add_field("size", str(size))
|
|
|
|
|
|
|
|
|
|
|
|
_add_image_edit_extra_body_to_form(form, extra_body)
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
2026-09-22 10:16:39 +08:00
|
|
|
|
image_inputs = list(_iter_image_reference_inputs(request_func_input.multi_modal_content))
|
2026-06-04 21:43:35 +08:00
|
|
|
|
if not image_inputs:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
"openai-image-edits-omni requires image multimodal content. "
|
|
|
|
|
|
"For synthetic inputs, use --dataset-name random-mm with an image bucket."
|
|
|
|
|
|
)
|
|
|
|
|
|
for image_input in image_inputs:
|
|
|
|
|
|
_add_image_edit_input_to_form(form, image_input)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
output.success = False
|
|
|
|
|
|
output.error = traceback.format_exc()
|
|
|
|
|
|
if pbar:
|
|
|
|
|
|
pbar.update(1)
|
|
|
|
|
|
return output
|
|
|
|
|
|
|
|
|
|
|
|
headers = {
|
|
|
|
|
|
"Authorization": f"Bearer {os.environ.get('OPENAI_API_KEY')}",
|
|
|
|
|
|
}
|
|
|
|
|
|
_update_headers_common(headers, request_func_input)
|
|
|
|
|
|
|
|
|
|
|
|
st = time.perf_counter()
|
|
|
|
|
|
output.start_time = st
|
|
|
|
|
|
try:
|
|
|
|
|
|
async with session.post(url=api_url, data=form, headers=headers) as response:
|
2026-09-08 09:28:21 +08:00
|
|
|
|
if response.status != 200:
|
|
|
|
|
|
error_text = await response.text()
|
|
|
|
|
|
_finalize_image_json_http_response(
|
|
|
|
|
|
output,
|
|
|
|
|
|
start_time=st,
|
|
|
|
|
|
status=response.status,
|
|
|
|
|
|
data=None,
|
|
|
|
|
|
error_text=error_text,
|
|
|
|
|
|
)
|
|
|
|
|
|
elif want_stream:
|
|
|
|
|
|
timestamp = st
|
|
|
|
|
|
most_recent_text_timestamp = st
|
|
|
|
|
|
generated_text = ""
|
2026-09-22 22:56:30 +08:00
|
|
|
|
streaming_error_received = False
|
2026-06-04 21:43:35 +08:00
|
|
|
|
handler = StreamedResponseHandler()
|
|
|
|
|
|
async for chunk_bytes in response.content.iter_any():
|
|
|
|
|
|
if not chunk_bytes:
|
|
|
|
|
|
continue
|
|
|
|
|
|
for message in handler.add_chunk(chunk_bytes):
|
|
|
|
|
|
if type(message) is bytes:
|
|
|
|
|
|
message = message.decode("utf-8")
|
|
|
|
|
|
if message.startswith(":"):
|
|
|
|
|
|
continue
|
|
|
|
|
|
chunk = message.removeprefix("data: ")
|
|
|
|
|
|
if chunk == "[DONE]":
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
timestamp = time.perf_counter()
|
|
|
|
|
|
data = json.loads(chunk)
|
2026-09-22 22:56:30 +08:00
|
|
|
|
if (streaming_error := data.get("error")) is not None:
|
|
|
|
|
|
streaming_error_received = True
|
|
|
|
|
|
if isinstance(streaming_error, dict):
|
|
|
|
|
|
output.error = str(streaming_error.get("message") or streaming_error)
|
|
|
|
|
|
else:
|
|
|
|
|
|
output.error = str(streaming_error)
|
|
|
|
|
|
continue
|
2026-06-04 21:43:35 +08:00
|
|
|
|
_update_output_stage_metrics_from_payload(
|
|
|
|
|
|
output,
|
|
|
|
|
|
data,
|
|
|
|
|
|
update_output_tokens=(data.get("type") == "ar_delta"),
|
|
|
|
|
|
)
|
2026-09-08 09:28:21 +08:00
|
|
|
|
_update_output_peak_memory_from_payload(output, data)
|
2026-09-22 10:16:39 +08:00
|
|
|
|
_update_output_stage_durations_from_payload(output, data)
|
2026-06-04 21:43:35 +08:00
|
|
|
|
|
|
|
|
|
|
chunk_type = data.get("type")
|
|
|
|
|
|
if chunk_type == "ar_delta":
|
|
|
|
|
|
if output.ttft == 0.0:
|
|
|
|
|
|
output.ttft = timestamp - st
|
|
|
|
|
|
else:
|
|
|
|
|
|
output.itl.append(timestamp - most_recent_text_timestamp)
|
|
|
|
|
|
delta = data.get("delta") or ""
|
|
|
|
|
|
generated_text += delta
|
|
|
|
|
|
most_recent_text_timestamp = timestamp
|
|
|
|
|
|
output.text_latency = timestamp - st
|
|
|
|
|
|
elif chunk_type == "image":
|
|
|
|
|
|
image_data = data.get("data")
|
|
|
|
|
|
output.image_count += len(image_data) if isinstance(image_data, list) else 1
|
|
|
|
|
|
content_image_ms = _image_generation_ms_from_content(data.get("data"))
|
|
|
|
|
|
if content_image_ms > 0:
|
|
|
|
|
|
output.image_generation_time_ms += content_image_ms
|
|
|
|
|
|
(
|
|
|
|
|
|
metrics_image_count,
|
|
|
|
|
|
metrics_image_ms,
|
|
|
|
|
|
metrics_image_pixels,
|
|
|
|
|
|
metrics_denoise_step_ms,
|
|
|
|
|
|
) = _image_metrics_from_stage_metrics(data.get("metrics"))
|
|
|
|
|
|
if metrics_image_count > output.image_count:
|
|
|
|
|
|
output.image_count = metrics_image_count
|
|
|
|
|
|
if metrics_image_ms > output.image_generation_time_ms:
|
|
|
|
|
|
output.image_generation_time_ms = metrics_image_ms
|
|
|
|
|
|
if metrics_image_pixels > output.image_pixels:
|
|
|
|
|
|
output.image_pixels = metrics_image_pixels
|
|
|
|
|
|
if metrics_denoise_step_ms > output.denoise_step_latency_ms:
|
|
|
|
|
|
output.denoise_step_latency_ms = metrics_denoise_step_ms
|
|
|
|
|
|
output.latency = timestamp - st
|
|
|
|
|
|
output.generated_text = generated_text
|
2026-09-22 22:56:30 +08:00
|
|
|
|
output.success = not streaming_error_received
|
2026-06-04 21:43:35 +08:00
|
|
|
|
else:
|
2026-09-08 09:28:21 +08:00
|
|
|
|
data = await response.json()
|
|
|
|
|
|
_finalize_image_json_http_response(
|
|
|
|
|
|
output,
|
|
|
|
|
|
start_time=st,
|
|
|
|
|
|
status=response.status,
|
|
|
|
|
|
data=data if isinstance(data, Mapping) else None,
|
|
|
|
|
|
error_text=None,
|
|
|
|
|
|
)
|
2026-06-04 21:43:35 +08:00
|
|
|
|
except Exception:
|
2026-09-08 09:28:21 +08:00
|
|
|
|
output.latency = time.perf_counter() - st
|
2026-06-04 21:43:35 +08:00
|
|
|
|
output.success = False
|
|
|
|
|
|
output.error = traceback.format_exc()
|
|
|
|
|
|
logger.error(f"ERROR: send image edit request failed, reason is: {output.error}")
|
|
|
|
|
|
|
|
|
|
|
|
if pbar:
|
|
|
|
|
|
pbar.update(1)
|
|
|
|
|
|
return output
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-27 01:31:52 -05:00
|
|
|
|
async def async_request_openai_audio_speech(
|
|
|
|
|
|
request_func_input: RequestFuncInput, session: aiohttp.ClientSession, pbar: tqdm | None = None
|
|
|
|
|
|
) -> MixRequestFuncOutput:
|
2026-03-11 13:07:47 -04:00
|
|
|
|
"""Streaming request to /v1/audio/speech endpoint.
|
2026-02-27 01:31:52 -05:00
|
|
|
|
|
2026-06-30 10:17:24 +08:00
|
|
|
|
Sends ``stream=true`` with ``stream_format=audio`` and ``response_format=pcm``
|
|
|
|
|
|
so the server returns raw PCM chunks as they are decoded. This allows measuring
|
|
|
|
|
|
TTFP (time to first audio packet) separately from E2EL.
|
2026-02-27 01:31:52 -05:00
|
|
|
|
"""
|
|
|
|
|
|
api_url = request_func_input.api_url
|
|
|
|
|
|
_validate_api_url(api_url, "OpenAI Audio Speech API", "audio/speech")
|
|
|
|
|
|
|
|
|
|
|
|
payload = {
|
|
|
|
|
|
"model": request_func_input.model_name if request_func_input.model_name else request_func_input.model,
|
|
|
|
|
|
"input": request_func_input.prompt,
|
2026-03-11 13:07:47 -04:00
|
|
|
|
"stream": True,
|
2026-06-30 10:17:24 +08:00
|
|
|
|
"stream_format": "audio",
|
2026-03-11 13:07:47 -04:00
|
|
|
|
"response_format": "pcm",
|
2026-02-27 01:31:52 -05:00
|
|
|
|
}
|
|
|
|
|
|
_update_payload_common(payload, request_func_input)
|
2026-04-15 11:07:35 +08:00
|
|
|
|
# Seed-TTS + WER: ``--extra-body`` may set stream=false / other formats; speech must stream PCM.
|
|
|
|
|
|
if getattr(request_func_input, "seed_tts_row", False) and _seed_tts_capture_pcm_for_wer():
|
|
|
|
|
|
payload["stream"] = True
|
2026-06-30 10:17:24 +08:00
|
|
|
|
payload["stream_format"] = "audio"
|
2026-04-15 11:07:35 +08:00
|
|
|
|
payload["response_format"] = "pcm"
|
2026-02-27 01:31:52 -05:00
|
|
|
|
|
|
|
|
|
|
headers = {
|
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
|
"Authorization": f"Bearer {os.environ.get('OPENAI_API_KEY')}",
|
|
|
|
|
|
}
|
|
|
|
|
|
_update_headers_common(headers, request_func_input)
|
|
|
|
|
|
|
|
|
|
|
|
output = MixRequestFuncOutput()
|
|
|
|
|
|
output.prompt_len = request_func_input.prompt_len
|
|
|
|
|
|
|
2026-07-03 18:15:03 +08:00
|
|
|
|
# PCM format: 16-bit signed; sample_rate/channels are model-dependent.
|
2026-10-07 17:07:49 -07:00
|
|
|
|
# The env vars are a fallback for servers that do not send format headers.
|
2026-07-03 18:15:03 +08:00
|
|
|
|
sample_rate, channels = defs.stream_pcm_format_from_env()
|
2026-07-23 23:55:05 +08:00
|
|
|
|
sample_width = defs.DEFAULT_AUDIO_SAMPLE_WIDTH
|
2026-03-11 13:07:47 -04:00
|
|
|
|
|
2026-02-27 01:31:52 -05:00
|
|
|
|
st = time.perf_counter()
|
|
|
|
|
|
output.start_time = st
|
2026-03-11 13:07:47 -04:00
|
|
|
|
total_pcm_bytes = 0
|
2026-04-15 11:07:35 +08:00
|
|
|
|
capture_wer_pcm = _seed_tts_capture_pcm_for_wer() and getattr(request_func_input, "seed_tts_row", False)
|
|
|
|
|
|
pcm_capture = bytearray() if capture_wer_pcm else None
|
2026-05-20 01:08:56 -04:00
|
|
|
|
chunk_arrival_times_s: list[float] = []
|
|
|
|
|
|
chunk_sizes: list[int] = []
|
2026-02-27 01:31:52 -05:00
|
|
|
|
try:
|
|
|
|
|
|
async with session.post(url=api_url, json=payload, headers=headers) as response:
|
|
|
|
|
|
if response.status == 200:
|
2026-10-07 17:07:49 -07:00
|
|
|
|
sample_rate, channels = _stream_pcm_format_from_headers(response.headers, sample_rate, channels)
|
2026-03-11 13:07:47 -04:00
|
|
|
|
async for chunk in response.content.iter_any():
|
|
|
|
|
|
if not chunk:
|
|
|
|
|
|
continue
|
|
|
|
|
|
timestamp = time.perf_counter()
|
|
|
|
|
|
if output.audio_ttfp == 0.0:
|
2026-05-18 05:18:18 -04:00
|
|
|
|
# TTS speech endpoint emits no text tokens, so TTFT is
|
|
|
|
|
|
# not defined here; only audio TTFP is meaningful.
|
2026-03-11 13:07:47 -04:00
|
|
|
|
output.audio_ttfp = timestamp - st
|
|
|
|
|
|
total_pcm_bytes += len(chunk)
|
2026-05-20 01:08:56 -04:00
|
|
|
|
chunk_arrival_times_s.append(timestamp - st)
|
|
|
|
|
|
chunk_sizes.append(len(chunk))
|
2026-04-15 11:07:35 +08:00
|
|
|
|
if pcm_capture is not None:
|
|
|
|
|
|
pcm_capture.extend(chunk)
|
2026-03-11 13:07:47 -04:00
|
|
|
|
|
2026-02-27 01:31:52 -05:00
|
|
|
|
end_time = time.perf_counter()
|
|
|
|
|
|
output.latency = end_time - st
|
|
|
|
|
|
|
2026-07-23 23:55:05 +08:00
|
|
|
|
total_samples = defs.compute_audio_frames(
|
|
|
|
|
|
total_pcm_bytes,
|
|
|
|
|
|
sample_width=sample_width,
|
|
|
|
|
|
channels=channels,
|
|
|
|
|
|
)
|
2026-03-11 13:07:47 -04:00
|
|
|
|
output.audio_frames = total_samples
|
2026-07-23 23:55:05 +08:00
|
|
|
|
output.audio_duration = (
|
|
|
|
|
|
float(total_samples) / float(sample_rate) if total_samples > 0 and sample_rate > 0 else 0.0
|
|
|
|
|
|
)
|
|
|
|
|
|
output.audio_rtf = defs.compute_audio_rtf(output.latency, output.audio_duration)
|
|
|
|
|
|
if output.audio_duration <= 0:
|
2026-03-11 13:07:47 -04:00
|
|
|
|
logger.warning("Audio duration is zero")
|
2026-05-20 01:08:56 -04:00
|
|
|
|
|
|
|
|
|
|
continuity = compute_continuity_stats(
|
|
|
|
|
|
chunk_arrival_times_s=chunk_arrival_times_s,
|
|
|
|
|
|
chunk_bytes=chunk_sizes,
|
|
|
|
|
|
sample_rate=sample_rate,
|
|
|
|
|
|
sample_width=sample_width,
|
|
|
|
|
|
channels=channels,
|
|
|
|
|
|
threshold_s=_audio_continuity_threshold_s(),
|
|
|
|
|
|
)
|
|
|
|
|
|
output.audio_underrun_s = continuity.max_underrun_s
|
|
|
|
|
|
output.audio_continuity_ok = continuity.is_continuous
|
|
|
|
|
|
output.audio_underrun_event_count = continuity.underrun_event_count
|
2026-04-15 11:07:35 +08:00
|
|
|
|
if pcm_capture is not None and pcm_capture:
|
2026-07-03 16:00:30 +08:00
|
|
|
|
try:
|
|
|
|
|
|
output.tts_output_pcm_bytes = _pcm_s16le_to_seed_tts_wer_bytes(
|
|
|
|
|
|
bytes(pcm_capture),
|
|
|
|
|
|
sample_rate=sample_rate,
|
|
|
|
|
|
channels=channels,
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception as ex:
|
|
|
|
|
|
logger.warning("Seed-TTS WER PCM normalization failed: %s", ex)
|
|
|
|
|
|
output.tts_output_pcm_bytes = bytes(pcm_capture)
|
2026-04-15 11:07:35 +08:00
|
|
|
|
elif capture_wer_pcm:
|
|
|
|
|
|
ct = response.headers.get("Content-Type", "")
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
"Seed-TTS WER: HTTP 200 but no PCM bytes (Content-Type=%r, url=%s). "
|
2026-06-30 10:17:24 +08:00
|
|
|
|
"Check stream=true, stream_format=audio, and response_format=pcm on the server.",
|
2026-04-15 11:07:35 +08:00
|
|
|
|
ct,
|
|
|
|
|
|
api_url,
|
|
|
|
|
|
)
|
2026-03-11 13:07:47 -04:00
|
|
|
|
output.success = True
|
2026-02-27 01:31:52 -05:00
|
|
|
|
else:
|
|
|
|
|
|
output.error = response.reason or ""
|
|
|
|
|
|
output.success = False
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
output.success = False
|
|
|
|
|
|
output.error = traceback.format_exc()
|
2026-01-30 11:41:09 +08:00
|
|
|
|
logger.error(f"ERROR: send request failed, reason is: {output.error}")
|
|
|
|
|
|
|
|
|
|
|
|
if pbar:
|
|
|
|
|
|
pbar.update(1)
|
|
|
|
|
|
return output
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-16 15:35:36 +08:00
|
|
|
|
#: Silence budget per Seed-TTS turn: a model-native duplex session generates
|
|
|
|
|
|
#: per audio unit, and the target text rides the session context, so the
|
|
|
|
|
|
#: silence only advances the clock. It stops at the turn's response.done --
|
|
|
|
|
|
#: a native model that keeps hearing silence after its turn may decide to
|
|
|
|
|
|
#: speak again, and the benchmark measures one response per utterance -- so
|
|
|
|
|
|
#: the budget is only spent on a turn the model is slow to take: it may
|
|
|
|
|
|
#: choose to listen on a few units first, and a turn that has not settled
|
|
|
|
|
|
#: when the budget runs out is reported with what the model did.
|
|
|
|
|
|
_SEED_TTS_SILENCE_SECONDS = 30.0
|
|
|
|
|
|
#: A native model normally answers the seeded text within this much silence;
|
|
|
|
|
|
#: a turn that needs more is logged so a slow-to-speak model shows in the run.
|
|
|
|
|
|
_SEED_TTS_PROMPT_RESPONSE_S = 12.0
|
|
|
|
|
|
#: MiniCPM-o emits 24 kHz mono; used to report audio_frames after the session closed.
|
|
|
|
|
|
_SEED_TTS_OUTPUT_SAMPLE_RATE_HZ = 24_000
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _seed_tts_turn_stall_report(events: object, response_offset: int, request_index: int, silence_s: float) -> str:
|
|
|
|
|
|
"""Explain a Seed-TTS turn that never settled: what the model did with the silence."""
|
|
|
|
|
|
response_ids = list(getattr(events, "response_ids")[response_offset:])
|
|
|
|
|
|
if not response_ids:
|
|
|
|
|
|
return (
|
|
|
|
|
|
f"Seed-TTS Realtime TTS turn {request_index} never started a response: the model listened "
|
|
|
|
|
|
f"through {silence_s:.1f}s of silence and the wait that followed"
|
|
|
|
|
|
)
|
|
|
|
|
|
audio_bytes = getattr(events, "audio_bytes")
|
|
|
|
|
|
response_text = getattr(events, "response_text")
|
|
|
|
|
|
started = ", ".join(
|
|
|
|
|
|
f"{response_id} ({len(audio_bytes(response_id))} audio bytes, text {response_text(response_id)!r})"
|
|
|
|
|
|
for response_id in response_ids
|
|
|
|
|
|
)
|
|
|
|
|
|
return (
|
|
|
|
|
|
f"Seed-TTS Realtime TTS turn {request_index} started {len(response_ids)} response(s) after "
|
|
|
|
|
|
f"{silence_s:.1f}s of silence but none reached response.done: {started}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _seed_tts_turn_response_id(events: object, response_offset: int, request_index: int) -> str:
|
|
|
|
|
|
"""The response id the Seed-TTS turn is measured on: the first one with audio.
|
|
|
|
|
|
|
|
|
|
|
|
A model-native session answers the seeded text once, but nothing in the
|
|
|
|
|
|
protocol stops it from speaking again on silence it hears afterwards, so
|
|
|
|
|
|
a later audio response is the model's own and not a failed turn.
|
|
|
|
|
|
"""
|
|
|
|
|
|
response_ids = getattr(events, "response_ids")
|
|
|
|
|
|
audio_bytes = getattr(events, "audio_bytes")
|
|
|
|
|
|
audio_response_ids = [response_id for response_id in response_ids[response_offset:] if audio_bytes(response_id)]
|
|
|
|
|
|
if not audio_response_ids:
|
|
|
|
|
|
raise RuntimeError(f"Seed-TTS Realtime TTS turn {request_index} produced no audio response")
|
|
|
|
|
|
if len(audio_response_ids) > 1:
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
"Seed-TTS Realtime TTS turn %d: model spoke again after its response (%d audio responses); "
|
|
|
|
|
|
"measuring the first",
|
|
|
|
|
|
request_index,
|
|
|
|
|
|
len(audio_response_ids),
|
|
|
|
|
|
)
|
|
|
|
|
|
return audio_response_ids[0]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-11 14:53:49 +08:00
|
|
|
|
def _realtime_websocket_url(api_url: str) -> str:
|
2026-09-05 16:20:21 +08:00
|
|
|
|
from vllm_omni.clients.duplex import build_realtime_url
|
|
|
|
|
|
|
2026-09-16 15:35:36 +08:00
|
|
|
|
return build_realtime_url(api_url, None)
|
2026-08-25 23:32:58 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _nonnegative_number(value: object) -> bool:
|
|
|
|
|
|
return isinstance(value, int | float) and not isinstance(value, bool) and np.isfinite(value) and value >= 0
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-29 19:56:37 +08:00
|
|
|
|
def _apply_stage0_token_timings(
|
|
|
|
|
|
output: MixRequestFuncOutput,
|
|
|
|
|
|
raw_stages: list[object],
|
|
|
|
|
|
*,
|
|
|
|
|
|
expected_output_tokens: int,
|
|
|
|
|
|
) -> bool:
|
|
|
|
|
|
"""Populate aggregate token timing from complete per-response Stage 0 metrics."""
|
|
|
|
|
|
output.itl = []
|
|
|
|
|
|
output.text_latency = output.ttft
|
|
|
|
|
|
output.tpot_measured = False
|
|
|
|
|
|
|
|
|
|
|
|
stages: list[tuple[int, dict[str, object]]] = []
|
|
|
|
|
|
for stage in raw_stages:
|
|
|
|
|
|
if not isinstance(stage, dict):
|
|
|
|
|
|
return False
|
|
|
|
|
|
token_count = stage.get("output_token_count")
|
|
|
|
|
|
if not isinstance(token_count, int) or isinstance(token_count, bool) or token_count < 0:
|
|
|
|
|
|
return False
|
|
|
|
|
|
stages.append((token_count, stage))
|
|
|
|
|
|
|
|
|
|
|
|
if not stages or sum(count for count, _ in stages) != expected_output_tokens:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
timed = [(count - 1, stage) for count, stage in stages if count > 1]
|
|
|
|
|
|
if not timed:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
measured_itls: list[float] = []
|
|
|
|
|
|
exact_itls = True
|
|
|
|
|
|
for intervals, stage in timed:
|
|
|
|
|
|
values = stage.get("itls_ms")
|
|
|
|
|
|
if (
|
|
|
|
|
|
not isinstance(values, list)
|
|
|
|
|
|
or len(values) != intervals
|
|
|
|
|
|
or not all(_nonnegative_number(value) for value in values)
|
|
|
|
|
|
):
|
|
|
|
|
|
exact_itls = False
|
|
|
|
|
|
break
|
|
|
|
|
|
measured_itls.extend(float(value) / 1000.0 for value in values if isinstance(value, int | float))
|
|
|
|
|
|
if exact_itls:
|
|
|
|
|
|
if sum(measured_itls) > 0:
|
|
|
|
|
|
output.itl = measured_itls
|
|
|
|
|
|
output.text_latency = output.ttft + sum(output.itl)
|
|
|
|
|
|
output.tpot_measured = True
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
weighted_tpot_ms = 0.0
|
|
|
|
|
|
for intervals, stage in timed:
|
|
|
|
|
|
tpot_ms = stage.get("tpot_ms")
|
|
|
|
|
|
if not _nonnegative_number(tpot_ms) or not isinstance(tpot_ms, int | float) or tpot_ms <= 0:
|
|
|
|
|
|
break
|
|
|
|
|
|
weighted_tpot_ms += float(tpot_ms) * intervals
|
|
|
|
|
|
else:
|
|
|
|
|
|
decode_intervals = sum(intervals for intervals, _ in timed)
|
|
|
|
|
|
weighted_tpot = weighted_tpot_ms / decode_intervals / 1000.0
|
|
|
|
|
|
# ``calculate_metrics`` consumes a request-shaped latency. Preserve
|
|
|
|
|
|
# the weighted per-response TPOT when projecting it onto the combined
|
|
|
|
|
|
# output token count; gaps and TTFTs between turns are intentionally
|
|
|
|
|
|
# excluded.
|
|
|
|
|
|
output.text_latency = output.ttft + weighted_tpot * (expected_output_tokens - 1)
|
|
|
|
|
|
output.tpot_measured = True
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-25 23:32:58 +08:00
|
|
|
|
async def _async_request_omniinteract(
|
|
|
|
|
|
request_func_input: RequestFuncInput,
|
|
|
|
|
|
*,
|
|
|
|
|
|
pbar: tqdm | None,
|
|
|
|
|
|
) -> MixRequestFuncOutput:
|
|
|
|
|
|
case = getattr(request_func_input, "omniinteract_case", None)
|
|
|
|
|
|
options = getattr(request_func_input, "omniinteract_options", None)
|
|
|
|
|
|
output = MixRequestFuncOutput()
|
|
|
|
|
|
output.prompt_len = request_func_input.prompt_len
|
|
|
|
|
|
output.start_time = time.perf_counter()
|
|
|
|
|
|
try:
|
|
|
|
|
|
if case is None or not isinstance(options, OmniInteractSessionOptions):
|
|
|
|
|
|
raise ValueError("OmniInteract RequestFuncInput is missing its dataset session layout")
|
|
|
|
|
|
prepared_input = getattr(request_func_input, "omniinteract_prepared_input", None)
|
|
|
|
|
|
if not isinstance(prepared_input, OmniInteractPreparedInput):
|
|
|
|
|
|
raise ValueError("OmniInteract media must be prepared before benchmark request timing")
|
|
|
|
|
|
headers = _get_headers()
|
|
|
|
|
|
_update_headers_common(headers, request_func_input)
|
|
|
|
|
|
config = OmniInteractBenchmarkConfig(
|
|
|
|
|
|
endpoint=request_func_input.api_url,
|
|
|
|
|
|
model=request_func_input.model_name or request_func_input.model,
|
|
|
|
|
|
output_root=options.output_root,
|
|
|
|
|
|
timeout_s=options.timeout_s,
|
|
|
|
|
|
media_timeout_s=options.media_timeout_s,
|
|
|
|
|
|
max_video_duration_s=options.max_video_duration_s,
|
|
|
|
|
|
ref_audio=options.ref_audio,
|
|
|
|
|
|
require_response=options.require_response,
|
|
|
|
|
|
extra_headers=headers or None,
|
|
|
|
|
|
extra_body=dict(request_func_input.extra_body or {}) or None,
|
|
|
|
|
|
)
|
|
|
|
|
|
case_result = await run_omniinteract_case(
|
|
|
|
|
|
case,
|
|
|
|
|
|
config,
|
|
|
|
|
|
request_index=request_func_input.request_id or uuid.uuid4().hex,
|
|
|
|
|
|
capture_artifacts=request_func_input.request_id is not None,
|
|
|
|
|
|
prepared_input=prepared_input,
|
|
|
|
|
|
)
|
|
|
|
|
|
output.latency = case_result.latency_s
|
|
|
|
|
|
output.generated_text = case_result.transcript
|
|
|
|
|
|
output.output_tokens = case_result.output_tokens
|
|
|
|
|
|
output.audio_duration = case_result.audio_bytes / (24_000 * 2)
|
|
|
|
|
|
output.audio_frames = case_result.audio_bytes // 2
|
|
|
|
|
|
session_metrics = case_result.duplex_session_metrics
|
2026-09-15 06:25:59 +08:00
|
|
|
|
from vllm_omni.clients.duplex import metric_mean
|
|
|
|
|
|
|
|
|
|
|
|
output.ttft = (metric_mean(session_metrics.get("ttft_ms")) or 0.0) / 1000.0
|
|
|
|
|
|
output.audio_ttfp = (metric_mean(session_metrics.get("ttfp_ms")) or 0.0) / 1000.0
|
|
|
|
|
|
output.audio_rtf = metric_mean(session_metrics.get("rtf")) or 0.0
|
2026-08-29 19:56:37 +08:00
|
|
|
|
token_timing_measured = _apply_stage0_token_timings(
|
|
|
|
|
|
output,
|
|
|
|
|
|
[request_metric.get("stage0_tokens") for request_metric in case_result.duplex_request_metrics],
|
|
|
|
|
|
expected_output_tokens=output.output_tokens,
|
2026-08-25 23:32:58 +08:00
|
|
|
|
)
|
2026-08-29 19:56:37 +08:00
|
|
|
|
if not token_timing_measured:
|
2026-08-25 23:32:58 +08:00
|
|
|
|
if output.output_tokens > 1 or (output.output_tokens == 0 and output.generated_text):
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
"OmniInteract session %s omitted complete engine token timing; standard TPOT/ITL are unavailable",
|
|
|
|
|
|
case_result.session_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
output.duplex_request_metrics = case_result.duplex_request_metrics
|
|
|
|
|
|
output.duplex_session_metrics = session_metrics
|
|
|
|
|
|
output.success = case_result.success
|
|
|
|
|
|
output.error = case_result.error
|
|
|
|
|
|
setattr(output, "omniinteract_case", case)
|
|
|
|
|
|
setattr(output, "omniinteract_case_result", case_result)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
output.success = False
|
|
|
|
|
|
output.error = traceback.format_exc()
|
|
|
|
|
|
logger.error("OmniInteract Realtime request failed: %s", output.error)
|
|
|
|
|
|
if case is not None:
|
|
|
|
|
|
case_result = OmniInteractCaseResult(
|
|
|
|
|
|
subset=case.subset,
|
|
|
|
|
|
video=str(case.video_path),
|
|
|
|
|
|
output_dir="",
|
|
|
|
|
|
error=output.error,
|
|
|
|
|
|
)
|
|
|
|
|
|
setattr(output, "omniinteract_case", case)
|
|
|
|
|
|
setattr(output, "omniinteract_case_result", case_result)
|
|
|
|
|
|
if pbar:
|
|
|
|
|
|
pbar.update(1)
|
|
|
|
|
|
return output
|
2026-08-11 14:53:49 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-09-05 16:20:21 +08:00
|
|
|
|
class _RealtimeTTSProbe:
|
|
|
|
|
|
"""Explicit-session Realtime TTS driver over the public duplex client.
|
|
|
|
|
|
|
|
|
|
|
|
Owns an :class:`EventCollector` (``.events``) so turn metrics can be read
|
|
|
|
|
|
with controlled timestamps; the session handshake happens in
|
|
|
|
|
|
:meth:`configure` because the benchmark decides the session shape at
|
|
|
|
|
|
request time.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
2026-09-16 15:35:36 +08:00
|
|
|
|
#: How long ``configure`` waits for a free duplex session. Every Seed-TTS
|
|
|
|
|
|
#: utterance is one session and the deploy config admits ``max_sessions``
|
|
|
|
|
|
#: of them, so a benchmark run above that concurrency queues for a slot
|
|
|
|
|
|
#: rather than counting the server's (retryable) refusal as a failed
|
|
|
|
|
|
#: request.
|
|
|
|
|
|
_SESSION_SLOT_WAIT_S = 120.0
|
|
|
|
|
|
|
2026-09-05 16:20:21 +08:00
|
|
|
|
def __init__(self, url: str) -> None:
|
|
|
|
|
|
from vllm_omni.clients.duplex import EventCollector
|
|
|
|
|
|
|
|
|
|
|
|
self._url = url
|
|
|
|
|
|
self.events: EventCollector = EventCollector()
|
|
|
|
|
|
self._client: DuplexClient | None = None
|
|
|
|
|
|
self._consume_task: asyncio.Task[None] | None = None
|
|
|
|
|
|
|
|
|
|
|
|
async def __aenter__(self) -> "_RealtimeTTSProbe":
|
|
|
|
|
|
return self
|
|
|
|
|
|
|
|
|
|
|
|
async def __aexit__(self, exc_type, exc, tb) -> None:
|
|
|
|
|
|
if self._client is not None:
|
|
|
|
|
|
await self._client.__aexit__(exc_type, exc, tb)
|
|
|
|
|
|
if self._consume_task is not None:
|
|
|
|
|
|
try:
|
|
|
|
|
|
await asyncio.wait_for(self._consume_task, timeout=5.0)
|
|
|
|
|
|
except (asyncio.TimeoutError, asyncio.CancelledError):
|
|
|
|
|
|
self._consume_task.cancel()
|
|
|
|
|
|
|
|
|
|
|
|
async def configure(
|
|
|
|
|
|
self,
|
|
|
|
|
|
model: str,
|
|
|
|
|
|
*,
|
|
|
|
|
|
output_audio_format: str = "pcm16",
|
|
|
|
|
|
instructions: str | None = None,
|
|
|
|
|
|
auto_response: bool = False,
|
|
|
|
|
|
extra_body: dict[str, object] | None = None,
|
|
|
|
|
|
timeout_s: float = 120.0,
|
|
|
|
|
|
) -> None:
|
2026-09-16 15:35:36 +08:00
|
|
|
|
from vllm_omni.clients.duplex import AudioFormat, DuplexClient, DuplexProtocolError, SessionConfig
|
2026-09-05 16:20:21 +08:00
|
|
|
|
|
|
|
|
|
|
session_extra_body: dict[str, object] = dict(extra_body or {})
|
|
|
|
|
|
config = SessionConfig(
|
|
|
|
|
|
output_audio=AudioFormat(output_audio_format, 24_000),
|
|
|
|
|
|
instructions=instructions,
|
|
|
|
|
|
auto_response=auto_response,
|
|
|
|
|
|
overlap_policy="listen_only",
|
|
|
|
|
|
playback_commit_policy="ack_only",
|
|
|
|
|
|
extra_body=session_extra_body,
|
|
|
|
|
|
)
|
2026-09-16 15:35:36 +08:00
|
|
|
|
deadline = time.monotonic() + self._SESSION_SLOT_WAIT_S
|
|
|
|
|
|
delay_s = 0.25
|
|
|
|
|
|
waited = False
|
|
|
|
|
|
while True:
|
|
|
|
|
|
# A failed handshake closes the socket on the client's side, so
|
|
|
|
|
|
# a refused attempt leaves nothing behind to clean up.
|
|
|
|
|
|
client = DuplexClient(
|
|
|
|
|
|
self._url,
|
|
|
|
|
|
model=model,
|
|
|
|
|
|
config=config,
|
|
|
|
|
|
reconnect=None,
|
|
|
|
|
|
heartbeat_interval_s=None,
|
|
|
|
|
|
handshake_timeout_s=timeout_s,
|
|
|
|
|
|
)
|
|
|
|
|
|
try:
|
|
|
|
|
|
await client.__aenter__()
|
|
|
|
|
|
except DuplexProtocolError as exc:
|
|
|
|
|
|
if exc.code != "resource_exhausted" or time.monotonic() >= deadline:
|
|
|
|
|
|
raise
|
|
|
|
|
|
if not waited:
|
|
|
|
|
|
logger.info("Seed-TTS Realtime TTS: no free duplex session (%s); waiting for a slot", exc)
|
|
|
|
|
|
waited = True
|
|
|
|
|
|
await asyncio.sleep(delay_s)
|
|
|
|
|
|
delay_s = min(delay_s * 2.0, 2.0)
|
|
|
|
|
|
continue
|
|
|
|
|
|
self._client = client
|
|
|
|
|
|
break
|
2026-09-05 16:20:21 +08:00
|
|
|
|
self._consume_task = asyncio.create_task(self.events.consume(self._client))
|
|
|
|
|
|
|
|
|
|
|
|
async def send(self, event: dict[str, object]) -> None:
|
|
|
|
|
|
assert self._client is not None
|
|
|
|
|
|
await self._client.send(event)
|
|
|
|
|
|
|
|
|
|
|
|
async def acknowledge_playback(self) -> None:
|
|
|
|
|
|
from vllm_omni.clients.duplex import acknowledge_collected_playback
|
|
|
|
|
|
|
|
|
|
|
|
assert self._client is not None
|
|
|
|
|
|
await acknowledge_collected_playback(self._client, self.events)
|
|
|
|
|
|
|
2026-09-16 15:35:36 +08:00
|
|
|
|
async def stream_silence(
|
|
|
|
|
|
self,
|
|
|
|
|
|
*,
|
|
|
|
|
|
seconds: float,
|
|
|
|
|
|
chunk_ms: int = 200,
|
|
|
|
|
|
until: Callable[[], bool] | None = None,
|
|
|
|
|
|
) -> float:
|
|
|
|
|
|
"""Append silent PCM16 units so a model-native session has units to speak on.
|
|
|
|
|
|
|
|
|
|
|
|
A duplex model generates per audio unit. The target text rides the
|
|
|
|
|
|
session context (``duplex_initial_user_text``), so the audio only has
|
|
|
|
|
|
to advance the clock; silence keeps it from adding content of its own.
|
|
|
|
|
|
Streams in real time for at most ``seconds``, stopping as soon as
|
|
|
|
|
|
``until`` holds, and returns the seconds actually appended.
|
|
|
|
|
|
"""
|
|
|
|
|
|
assert self._client is not None
|
|
|
|
|
|
input_format = self._client.config.input_audio
|
|
|
|
|
|
chunk = bytes(max(input_format.byte_count(chunk_ms), input_format.bytes_per_sample))
|
|
|
|
|
|
chunk_s = input_format.duration_ms(len(chunk)) / 1000.0
|
|
|
|
|
|
streamed_s = 0.0
|
|
|
|
|
|
while streamed_s < seconds and not (until is not None and until()):
|
|
|
|
|
|
await self._client.append_audio(chunk, is_speech=False)
|
|
|
|
|
|
streamed_s += chunk_s
|
|
|
|
|
|
await asyncio.sleep(chunk_s)
|
|
|
|
|
|
return streamed_s
|
|
|
|
|
|
|
2026-09-05 16:20:21 +08:00
|
|
|
|
async def close_session(self, *, timeout_s: float = 20.0) -> None:
|
|
|
|
|
|
assert self._client is not None
|
|
|
|
|
|
await self._client.close(timeout_s=timeout_s)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-11 14:53:49 +08:00
|
|
|
|
async def async_request_openai_realtime_duplex(
|
|
|
|
|
|
request_func_input: RequestFuncInput,
|
|
|
|
|
|
session: aiohttp.ClientSession,
|
|
|
|
|
|
pbar: tqdm | None = None,
|
|
|
|
|
|
) -> MixRequestFuncOutput:
|
2026-09-05 16:20:21 +08:00
|
|
|
|
from vllm_omni.clients.duplex import summarize_session_request_metrics, wait_for_condition
|
|
|
|
|
|
|
2026-08-11 14:53:49 +08:00
|
|
|
|
del session
|
2026-08-25 23:32:58 +08:00
|
|
|
|
if getattr(request_func_input, "omniinteract_case", None) is not None:
|
|
|
|
|
|
return await _async_request_omniinteract(request_func_input, pbar=pbar)
|
2026-08-11 14:53:49 +08:00
|
|
|
|
output = MixRequestFuncOutput()
|
|
|
|
|
|
output.prompt_len = request_func_input.prompt_len
|
|
|
|
|
|
output.start_time = time.perf_counter()
|
|
|
|
|
|
speech_extra = getattr(request_func_input, "seed_tts_speech_extra", None)
|
|
|
|
|
|
if not isinstance(speech_extra, dict):
|
|
|
|
|
|
speech_extra = {}
|
2026-08-29 19:56:37 +08:00
|
|
|
|
else:
|
|
|
|
|
|
speech_extra = dict(speech_extra)
|
|
|
|
|
|
speech_extra["return_stage_metrics"] = True
|
2026-08-11 14:53:49 +08:00
|
|
|
|
configured_turns = getattr(request_func_input, "seed_tts_turns", ())
|
|
|
|
|
|
turn_prompts = [
|
|
|
|
|
|
(
|
|
|
|
|
|
str(getattr(turn, "utterance_id", "") or ""),
|
|
|
|
|
|
str(getattr(turn, "target_text", "") or ""),
|
|
|
|
|
|
)
|
|
|
|
|
|
for turn in configured_turns
|
|
|
|
|
|
if str(getattr(turn, "target_text", "") or "").strip()
|
|
|
|
|
|
]
|
|
|
|
|
|
if not turn_prompts:
|
|
|
|
|
|
turn_prompts = [("", request_func_input.prompt)]
|
|
|
|
|
|
session_id = f"seed-tts-{request_func_input.request_id or uuid.uuid4().hex}"
|
2026-09-16 15:35:36 +08:00
|
|
|
|
silence_seconds = float(getattr(request_func_input, "seed_tts_silence_seconds", 0.0) or _SEED_TTS_SILENCE_SECONDS)
|
|
|
|
|
|
turn_metrics: list[dict[str, object]] = []
|
|
|
|
|
|
turn_timings: list[dict[str, object]] = []
|
|
|
|
|
|
turn_pcm_bytes: list[bytes] = []
|
|
|
|
|
|
turn_transcripts: list[str] = []
|
|
|
|
|
|
measurement_origin = {
|
|
|
|
|
|
"ttft": "first silence append client send to first non-empty text delta",
|
|
|
|
|
|
"ttfp": "first silence append client send to first audio packet",
|
|
|
|
|
|
"rtf": "request-start-to-last-audio receive time divided by emitted audio duration",
|
|
|
|
|
|
}
|
2026-08-11 14:53:49 +08:00
|
|
|
|
try:
|
2026-09-16 15:35:36 +08:00
|
|
|
|
# One session per utterance. A model-native duplex session takes its
|
|
|
|
|
|
# text once, in the session context (``duplex_initial_user_text``), so
|
|
|
|
|
|
# a session cannot be re-seeded for a second target text.
|
|
|
|
|
|
for request_index, (utterance_id, target_text) in enumerate(turn_prompts):
|
|
|
|
|
|
async with _RealtimeTTSProbe(_realtime_websocket_url(request_func_input.api_url)) as client:
|
|
|
|
|
|
await client.configure(
|
|
|
|
|
|
request_func_input.model_name or request_func_input.model,
|
|
|
|
|
|
output_audio_format="pcm16",
|
|
|
|
|
|
instructions=getattr(
|
|
|
|
|
|
request_func_input,
|
|
|
|
|
|
"seed_tts_system_prompt",
|
|
|
|
|
|
SEED_TTS_DEFAULT_OMNI_SYSTEM_PROMPT,
|
|
|
|
|
|
),
|
|
|
|
|
|
auto_response=True,
|
|
|
|
|
|
extra_body={
|
|
|
|
|
|
**speech_extra,
|
|
|
|
|
|
"duplex_initial_user_text": target_text,
|
|
|
|
|
|
"force_listen_count": 0,
|
|
|
|
|
|
},
|
|
|
|
|
|
timeout_s=120.0,
|
|
|
|
|
|
)
|
2026-08-11 14:53:49 +08:00
|
|
|
|
response_offset = len(client.events.response_ids)
|
|
|
|
|
|
done_before = client.events.count("response.done")
|
|
|
|
|
|
errors_before = len(client.events.errors())
|
2026-09-16 15:35:36 +08:00
|
|
|
|
|
|
|
|
|
|
def turn_settled() -> bool:
|
|
|
|
|
|
return (
|
2026-08-25 23:32:58 +08:00
|
|
|
|
client.events.count("response.done") > done_before
|
|
|
|
|
|
or len(client.events.errors()) > errors_before
|
2026-09-16 15:35:36 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
turn_started_at_s = time.monotonic()
|
|
|
|
|
|
silence_s = await client.stream_silence(seconds=silence_seconds, until=turn_settled)
|
|
|
|
|
|
if silence_s > _SEED_TTS_PROMPT_RESPONSE_S:
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
"Seed-TTS Realtime TTS turn %d: the model took %.1fs of silence to settle its response",
|
|
|
|
|
|
request_index,
|
|
|
|
|
|
silence_s,
|
|
|
|
|
|
)
|
|
|
|
|
|
try:
|
|
|
|
|
|
await wait_for_condition(
|
|
|
|
|
|
turn_settled,
|
|
|
|
|
|
timeout_s=180.0,
|
|
|
|
|
|
label=f"Seed-TTS Realtime TTS turn {request_index} response.done",
|
|
|
|
|
|
)
|
|
|
|
|
|
except TimeoutError as exc:
|
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
|
_seed_tts_turn_stall_report(client.events, response_offset, request_index, silence_s)
|
|
|
|
|
|
) from exc
|
2026-08-11 14:53:49 +08:00
|
|
|
|
errors = client.events.errors()
|
|
|
|
|
|
if len(errors) > errors_before:
|
|
|
|
|
|
raise RuntimeError(f"Seed-TTS Realtime TTS server error: {errors[-1]}")
|
2026-09-16 15:35:36 +08:00
|
|
|
|
response_id = _seed_tts_turn_response_id(client.events, response_offset, request_index)
|
2026-08-11 14:53:49 +08:00
|
|
|
|
timing = client.events.timing_summary(
|
|
|
|
|
|
after_s=turn_started_at_s,
|
|
|
|
|
|
input_committed_at_s=turn_started_at_s,
|
|
|
|
|
|
response_id=response_id,
|
|
|
|
|
|
measurement_origin=measurement_origin,
|
|
|
|
|
|
)
|
|
|
|
|
|
request_metrics = timing.get("request_metrics")
|
|
|
|
|
|
if not isinstance(request_metrics, dict):
|
|
|
|
|
|
raise RuntimeError(f"Seed-TTS duplex audio turn {response_id} omitted per-request metrics")
|
|
|
|
|
|
turn_timings.append(timing)
|
|
|
|
|
|
turn_metrics.append(
|
|
|
|
|
|
{
|
|
|
|
|
|
"session_id": session_id,
|
|
|
|
|
|
"request_index": request_index,
|
|
|
|
|
|
"utterance_id": utterance_id or None,
|
|
|
|
|
|
"response_id": response_id,
|
|
|
|
|
|
**request_metrics,
|
2026-09-05 16:20:21 +08:00
|
|
|
|
# The client reports raw data only; the RTF is derived
|
|
|
|
|
|
# here with the canonical server-side definition.
|
|
|
|
|
|
"rtf": _audio_rtf_from_raw_metric(request_metrics),
|
2026-08-11 14:53:49 +08:00
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
response_audio = client.events.audio_bytes(response_id)
|
|
|
|
|
|
turn_pcm_bytes.append(
|
|
|
|
|
|
_pcm_s16le_to_seed_tts_wer_bytes(
|
|
|
|
|
|
response_audio,
|
|
|
|
|
|
sample_rate=client.events.output_sample_rate_hz,
|
|
|
|
|
|
channels=1,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
2026-08-25 23:32:58 +08:00
|
|
|
|
turn_transcripts.append(client.events.response_text(response_id))
|
2026-08-11 14:53:49 +08:00
|
|
|
|
await client.acknowledge_playback()
|
2026-09-16 15:35:36 +08:00
|
|
|
|
await client.close_session(timeout_s=30.0)
|
|
|
|
|
|
request_finished_at = time.perf_counter()
|
|
|
|
|
|
session_metrics = summarize_session_request_metrics(
|
|
|
|
|
|
turn_metrics,
|
|
|
|
|
|
session_id=session_id,
|
|
|
|
|
|
)
|
2026-08-11 14:53:49 +08:00
|
|
|
|
|
2026-09-16 15:35:36 +08:00
|
|
|
|
output.generated_text = " ".join(filter(None, turn_transcripts))
|
|
|
|
|
|
from vllm_omni.clients.duplex import metric_mean
|
2026-09-15 06:25:59 +08:00
|
|
|
|
|
2026-09-16 15:35:36 +08:00
|
|
|
|
output.ttft = (metric_mean(session_metrics.get("ttft_ms")) or 0.0) / 1000.0
|
|
|
|
|
|
output.audio_ttfp = (metric_mean(session_metrics.get("ttfp_ms")) or 0.0) / 1000.0
|
|
|
|
|
|
output.audio_rtf = metric_mean(session_metrics.get("rtf")) or 0.0
|
2026-09-20 17:15:53 +08:00
|
|
|
|
output.audio_duration = (
|
|
|
|
|
|
sum((_as_float(metric.get("audio_duration_ms")) for metric in turn_metrics), start=0.0) / 1000.0
|
|
|
|
|
|
)
|
2026-09-16 15:35:36 +08:00
|
|
|
|
output.audio_frames = int(output.audio_duration * _SEED_TTS_OUTPUT_SAMPLE_RATE_HZ)
|
|
|
|
|
|
output.latency = request_finished_at - output.start_time
|
|
|
|
|
|
output.tts_turn_pcm_bytes = turn_pcm_bytes
|
|
|
|
|
|
output.tts_output_pcm_bytes = b"".join(turn_pcm_bytes)
|
|
|
|
|
|
if bool((request_func_input.extra_body or {}).get("save_duplex_request_metrics")):
|
|
|
|
|
|
output.duplex_request_metrics = turn_metrics
|
|
|
|
|
|
output.duplex_session_metrics = session_metrics
|
|
|
|
|
|
output.output_tokens = sum(
|
|
|
|
|
|
int(stage0.get("output_token_count") or 0)
|
|
|
|
|
|
for timing in turn_timings
|
|
|
|
|
|
if isinstance((stage0 := timing.get("stage0_tokens")), dict)
|
|
|
|
|
|
)
|
|
|
|
|
|
token_timing_measured = _apply_stage0_token_timings(
|
|
|
|
|
|
output,
|
|
|
|
|
|
[timing.get("stage0_tokens") for timing in turn_timings],
|
|
|
|
|
|
expected_output_tokens=output.output_tokens,
|
|
|
|
|
|
)
|
|
|
|
|
|
if not token_timing_measured and output.output_tokens > 1:
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
"Realtime TTS session %s omitted complete engine token timing; standard TPOT/ITL are unavailable",
|
|
|
|
|
|
session_id,
|
2026-08-11 14:53:49 +08:00
|
|
|
|
)
|
2026-09-16 15:35:36 +08:00
|
|
|
|
output.success = True
|
2026-08-11 14:53:49 +08:00
|
|
|
|
except Exception:
|
|
|
|
|
|
output.success = False
|
|
|
|
|
|
output.error = traceback.format_exc()
|
|
|
|
|
|
logger.error(
|
|
|
|
|
|
"Seed-TTS Realtime TTS request failed: %s",
|
|
|
|
|
|
output.error,
|
|
|
|
|
|
)
|
|
|
|
|
|
if pbar:
|
|
|
|
|
|
pbar.update(1)
|
|
|
|
|
|
return output
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-01-30 11:41:09 +08:00
|
|
|
|
ASYNC_REQUEST_FUNCS["openai-chat-omni"] = async_request_openai_chat_omni_completions
|
|
|
|
|
|
if "openai-chat-omni" not in OPENAI_COMPATIBLE_BACKENDS:
|
|
|
|
|
|
OPENAI_COMPATIBLE_BACKENDS.append("openai-chat-omni")
|
|
|
|
|
|
|
2026-09-08 09:28:21 +08:00
|
|
|
|
ASYNC_REQUEST_FUNCS["/v1/images/edits"] = async_request_openai_image_edits_omni
|
|
|
|
|
|
if "/v1/images/edits" not in OPENAI_COMPATIBLE_BACKENDS:
|
|
|
|
|
|
OPENAI_COMPATIBLE_BACKENDS.append("/v1/images/edits")
|
|
|
|
|
|
|
|
|
|
|
|
ASYNC_REQUEST_FUNCS["/v1/images/generations"] = async_request_openai_image_generations_omni
|
|
|
|
|
|
if "/v1/images/generations" not in OPENAI_COMPATIBLE_BACKENDS:
|
|
|
|
|
|
OPENAI_COMPATIBLE_BACKENDS.append("/v1/images/generations")
|
|
|
|
|
|
|
|
|
|
|
|
ASYNC_REQUEST_FUNCS["/v1/videos"] = async_request_openai_videos_omni
|
|
|
|
|
|
if "/v1/videos" not in OPENAI_COMPATIBLE_BACKENDS:
|
|
|
|
|
|
OPENAI_COMPATIBLE_BACKENDS.append("/v1/videos")
|
|
|
|
|
|
|
2026-02-27 01:31:52 -05:00
|
|
|
|
ASYNC_REQUEST_FUNCS["openai-audio-speech"] = async_request_openai_audio_speech
|
|
|
|
|
|
if "openai-audio-speech" not in OPENAI_COMPATIBLE_BACKENDS:
|
|
|
|
|
|
OPENAI_COMPATIBLE_BACKENDS.append("openai-audio-speech")
|
|
|
|
|
|
|
2026-08-11 14:53:49 +08:00
|
|
|
|
ASYNC_REQUEST_FUNCS["openai-realtime-duplex"] = async_request_openai_realtime_duplex
|
|
|
|
|
|
if "openai-realtime-duplex" not in OPENAI_COMPATIBLE_BACKENDS:
|
|
|
|
|
|
OPENAI_COMPATIBLE_BACKENDS.append("openai-realtime-duplex")
|
|
|
|
|
|
ASYNC_REQUEST_FUNCS["openai-realtime-tts"] = async_request_openai_realtime_duplex
|
|
|
|
|
|
if "openai-realtime-tts" not in OPENAI_COMPATIBLE_BACKENDS:
|
|
|
|
|
|
OPENAI_COMPATIBLE_BACKENDS.append("openai-realtime-tts")
|
|
|
|
|
|
|
2026-06-04 21:43:35 +08:00
|
|
|
|
ASYNC_REQUEST_FUNCS["openai-image-edits-omni"] = async_request_openai_image_edits_omni
|
|
|
|
|
|
if "openai-image-edits-omni" not in OPENAI_COMPATIBLE_BACKENDS:
|
|
|
|
|
|
OPENAI_COMPATIBLE_BACKENDS.append("openai-image-edits-omni")
|
|
|
|
|
|
|
2026-04-15 11:07:35 +08:00
|
|
|
|
# Daily-Omni backend for audio-visual reasoning benchmark
|
|
|
|
|
|
# Reuses openai-chat-omni completions for video+text understanding
|
|
|
|
|
|
ASYNC_REQUEST_FUNCS["daily-omni"] = async_request_openai_chat_omni_completions
|
|
|
|
|
|
if "daily-omni" not in OPENAI_COMPATIBLE_BACKENDS:
|
|
|
|
|
|
OPENAI_COMPATIBLE_BACKENDS.append("daily-omni")
|
|
|
|
|
|
|
2026-01-30 11:41:09 +08:00
|
|
|
|
# ruff: noqa: E402
|
|
|
|
|
|
# Prevent import order from causing patch failures
|
|
|
|
|
|
from vllm.benchmarks import serve
|
2026-04-30 05:56:59 +08:00
|
|
|
|
from vllm.benchmarks.lib.ready_checker import wait_for_endpoint
|
2026-07-04 13:30:48 +08:00
|
|
|
|
from vllm.benchmarks.serve import TaskType, calculate_metrics_for_embeddings, get_request
|
2026-01-30 11:41:09 +08:00
|
|
|
|
|
2026-06-04 21:43:35 +08:00
|
|
|
|
from vllm_omni.benchmarks.metrics.metrics import (
|
|
|
|
|
|
MultiModalsBenchmarkMetrics,
|
|
|
|
|
|
calculate_metrics,
|
2026-08-25 23:32:58 +08:00
|
|
|
|
has_metric_samples,
|
2026-06-04 21:43:35 +08:00
|
|
|
|
)
|
2026-01-30 11:41:09 +08:00
|
|
|
|
|
|
|
|
|
|
# ruff: noqa: E402
|
|
|
|
|
|
|
|
|
|
|
|
benchmark_old = serve.benchmark
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-04 13:30:48 +08:00
|
|
|
|
def _merge_overrides(base: dict | None, overrides: dict | None) -> dict | None:
|
|
|
|
|
|
"""Merge benchmark extra_body with per-request overrides.
|
|
|
|
|
|
|
|
|
|
|
|
vLLM 0.24 removed the private helper from ``vllm.benchmarks.serve``.
|
|
|
|
|
|
Keep the same shallow-merge behavior here, with request overrides winning.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not base and not overrides:
|
|
|
|
|
|
return None
|
|
|
|
|
|
merged = dict(base or {})
|
|
|
|
|
|
merged.update(overrides or {})
|
|
|
|
|
|
return merged
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-01-30 11:41:09 +08:00
|
|
|
|
async def benchmark(
|
|
|
|
|
|
task_type: TaskType,
|
|
|
|
|
|
endpoint_type: str,
|
|
|
|
|
|
api_url: str,
|
|
|
|
|
|
base_url: str,
|
|
|
|
|
|
model_id: str,
|
|
|
|
|
|
model_name: str,
|
2026-03-09 17:29:38 +08:00
|
|
|
|
tokenizer: TokenizerLike | None,
|
2026-01-30 11:41:09 +08:00
|
|
|
|
input_requests: list[SampleRequest],
|
|
|
|
|
|
logprobs: int | None,
|
|
|
|
|
|
request_rate: float,
|
|
|
|
|
|
burstiness: float,
|
|
|
|
|
|
disable_tqdm: bool,
|
|
|
|
|
|
num_warmups: int,
|
|
|
|
|
|
profile: bool,
|
|
|
|
|
|
selected_percentile_metrics: list[str],
|
|
|
|
|
|
selected_percentiles: list[float],
|
|
|
|
|
|
ignore_eos: bool,
|
|
|
|
|
|
goodput_config_dict: dict[str, float],
|
|
|
|
|
|
max_concurrency: int | None,
|
|
|
|
|
|
lora_modules: Iterable[str] | None,
|
|
|
|
|
|
extra_headers: dict | None,
|
|
|
|
|
|
extra_body: dict | None,
|
2026-04-04 15:03:32 +08:00
|
|
|
|
lora_assignment: Literal["random", "round-robin"] = "random",
|
2026-01-30 11:41:09 +08:00
|
|
|
|
ramp_up_strategy: Literal["linear", "exponential"] | None = None,
|
|
|
|
|
|
ramp_up_start_rps: int | None = None,
|
|
|
|
|
|
ramp_up_end_rps: int | None = None,
|
|
|
|
|
|
ready_check_timeout_sec: int = 600,
|
2026-02-13 18:05:07 +08:00
|
|
|
|
ssl_context: ssl.SSLContext | bool | None = None,
|
2026-06-14 00:43:02 +08:00
|
|
|
|
self_timed: bool = False,
|
2026-08-11 23:23:46 +08:00
|
|
|
|
probe_request_rate: float = 0.0,
|
2026-01-30 11:41:09 +08:00
|
|
|
|
):
|
|
|
|
|
|
try:
|
|
|
|
|
|
request_func = ASYNC_REQUEST_FUNCS[endpoint_type]
|
|
|
|
|
|
except KeyError:
|
|
|
|
|
|
raise ValueError(f"Unknown backend: {endpoint_type}") from None
|
|
|
|
|
|
|
|
|
|
|
|
# Reuses connections across requests to reduce TLS handshake overhead.
|
2026-02-13 18:05:07 +08:00
|
|
|
|
ssl_setting = ssl_context if ssl_context is not None else ("https://" in api_url)
|
2026-09-08 17:00:25 +08:00
|
|
|
|
session = _build_benchmark_session(max_concurrency, ssl_setting)
|
|
|
|
|
|
print(f"Per-request timeout: {_omni_request_timeout_s():g}s")
|
2026-01-30 11:41:09 +08:00
|
|
|
|
|
|
|
|
|
|
print("Starting initial single prompt test run...")
|
|
|
|
|
|
test_prompt, test_prompt_len, test_output_len, test_mm_content = (
|
|
|
|
|
|
input_requests[0].prompt,
|
|
|
|
|
|
input_requests[0].prompt_len,
|
|
|
|
|
|
input_requests[0].expected_output_len,
|
|
|
|
|
|
input_requests[0].multi_modal_data,
|
|
|
|
|
|
)
|
2026-06-30 19:41:06 +08:00
|
|
|
|
test_extra_body = _merge_overrides(extra_body, input_requests[0].request_overrides)
|
|
|
|
|
|
test_chat_messages = input_requests[0].chat_messages
|
2026-01-30 11:41:09 +08:00
|
|
|
|
|
|
|
|
|
|
assert (
|
|
|
|
|
|
test_mm_content is None
|
|
|
|
|
|
or isinstance(test_mm_content, dict)
|
|
|
|
|
|
or (isinstance(test_mm_content, list) and all(isinstance(item, dict) for item in test_mm_content))
|
|
|
|
|
|
), "multi_modal_data must be a dict or list[dict]"
|
|
|
|
|
|
test_input = RequestFuncInput(
|
|
|
|
|
|
model=model_id,
|
|
|
|
|
|
model_name=model_name,
|
|
|
|
|
|
prompt=test_prompt,
|
|
|
|
|
|
api_url=api_url,
|
|
|
|
|
|
prompt_len=test_prompt_len,
|
|
|
|
|
|
output_len=test_output_len,
|
|
|
|
|
|
logprobs=logprobs,
|
|
|
|
|
|
multi_modal_content=test_mm_content,
|
|
|
|
|
|
ignore_eos=ignore_eos,
|
|
|
|
|
|
extra_headers=extra_headers,
|
2026-06-30 19:41:06 +08:00
|
|
|
|
extra_body=test_extra_body,
|
|
|
|
|
|
chat_messages=test_chat_messages,
|
2026-01-30 11:41:09 +08:00
|
|
|
|
)
|
2026-09-20 17:15:53 +08:00
|
|
|
|
_attach_omni_chat_to_request_func_input(input_requests[0], test_input)
|
2026-04-15 11:07:35 +08:00
|
|
|
|
_attach_seed_tts_to_request_func_input(input_requests[0], test_input)
|
2026-08-25 23:32:58 +08:00
|
|
|
|
_attach_omniinteract_to_request_func_input(input_requests[0], test_input)
|
2026-01-30 11:41:09 +08:00
|
|
|
|
|
|
|
|
|
|
if ready_check_timeout_sec > 0:
|
|
|
|
|
|
test_output = await wait_for_endpoint(
|
|
|
|
|
|
request_func,
|
|
|
|
|
|
test_input,
|
|
|
|
|
|
session,
|
|
|
|
|
|
timeout_seconds=ready_check_timeout_sec,
|
|
|
|
|
|
)
|
|
|
|
|
|
if not test_output.success:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
"Initial test run failed - Please make sure benchmark "
|
|
|
|
|
|
"arguments are correctly specified. "
|
|
|
|
|
|
f"Error: {test_output.error}"
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
print("Initial test run completed.")
|
|
|
|
|
|
else:
|
|
|
|
|
|
print("Skipping endpoint ready check.")
|
|
|
|
|
|
|
|
|
|
|
|
if num_warmups > 0:
|
|
|
|
|
|
print(f"Warming up with {num_warmups} requests...")
|
|
|
|
|
|
warmup_pbar = None if disable_tqdm else tqdm(total=num_warmups)
|
2026-09-20 17:15:53 +08:00
|
|
|
|
warmup_semaphore = _async_limiter(max_concurrency)
|
2026-01-30 11:41:09 +08:00
|
|
|
|
warmup_tasks = []
|
|
|
|
|
|
|
|
|
|
|
|
async def warmup_limited_request_func():
|
|
|
|
|
|
async with warmup_semaphore:
|
|
|
|
|
|
return await request_func(request_func_input=test_input, session=session, pbar=warmup_pbar)
|
|
|
|
|
|
|
|
|
|
|
|
for _ in range(num_warmups):
|
|
|
|
|
|
request_task = asyncio.create_task(warmup_limited_request_func())
|
|
|
|
|
|
warmup_tasks.append(request_task)
|
|
|
|
|
|
_ = await asyncio.gather(*warmup_tasks)
|
|
|
|
|
|
|
|
|
|
|
|
if warmup_pbar is not None:
|
|
|
|
|
|
warmup_pbar.close()
|
|
|
|
|
|
print("Warmup run completed.")
|
|
|
|
|
|
|
|
|
|
|
|
print("Starting main benchmark run...")
|
|
|
|
|
|
|
2026-09-20 17:15:53 +08:00
|
|
|
|
lora_iter: Iterator[str] | None = None
|
2026-01-30 11:41:09 +08:00
|
|
|
|
if lora_modules:
|
2026-04-04 15:03:32 +08:00
|
|
|
|
lora_modules_list = list(lora_modules)
|
|
|
|
|
|
if lora_assignment == "round-robin":
|
2026-09-20 17:15:53 +08:00
|
|
|
|
lora_iter = iter([lora_modules_list[i % len(lora_modules_list)] for i in range(len(input_requests))])
|
2026-04-04 15:03:32 +08:00
|
|
|
|
else:
|
2026-09-20 17:15:53 +08:00
|
|
|
|
lora_iter = iter([random.choice(lora_modules_list) for _ in range(len(input_requests))])
|
2026-01-30 11:41:09 +08:00
|
|
|
|
|
|
|
|
|
|
if profile:
|
|
|
|
|
|
print("Starting profiler...")
|
|
|
|
|
|
profile_input = RequestFuncInput(
|
|
|
|
|
|
model=model_id,
|
|
|
|
|
|
model_name=model_name,
|
|
|
|
|
|
prompt=test_prompt,
|
|
|
|
|
|
api_url=base_url + "/start_profile",
|
|
|
|
|
|
prompt_len=test_prompt_len,
|
|
|
|
|
|
output_len=test_output_len,
|
|
|
|
|
|
logprobs=logprobs,
|
|
|
|
|
|
multi_modal_content=test_mm_content,
|
|
|
|
|
|
ignore_eos=ignore_eos,
|
|
|
|
|
|
extra_headers=extra_headers,
|
2026-06-30 19:41:06 +08:00
|
|
|
|
extra_body=test_extra_body,
|
|
|
|
|
|
chat_messages=test_chat_messages,
|
2026-01-30 11:41:09 +08:00
|
|
|
|
)
|
2026-09-20 17:15:53 +08:00
|
|
|
|
_attach_omni_chat_to_request_func_input(input_requests[0], profile_input)
|
2026-04-15 11:07:35 +08:00
|
|
|
|
_attach_seed_tts_to_request_func_input(input_requests[0], profile_input)
|
2026-01-30 11:41:09 +08:00
|
|
|
|
profile_output = await request_func(request_func_input=profile_input, session=session)
|
|
|
|
|
|
if profile_output.success:
|
|
|
|
|
|
print("Profiler started")
|
|
|
|
|
|
|
|
|
|
|
|
distribution = "Poisson process" if burstiness == 1.0 else "Gamma distribution"
|
|
|
|
|
|
|
|
|
|
|
|
if ramp_up_strategy is not None:
|
|
|
|
|
|
print(f"Traffic ramp-up strategy: {ramp_up_strategy}.")
|
|
|
|
|
|
print(
|
|
|
|
|
|
f"Will increase RPS from {ramp_up_start_rps} to {ramp_up_end_rps} RPS over the duration of the benchmark."
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
print(f"Traffic request rate: {request_rate}")
|
|
|
|
|
|
|
|
|
|
|
|
print(f"Burstiness factor: {burstiness} ({distribution})")
|
|
|
|
|
|
print(f"Maximum request concurrency: {max_concurrency}")
|
|
|
|
|
|
|
|
|
|
|
|
pbar = None if disable_tqdm else tqdm(total=len(input_requests))
|
|
|
|
|
|
|
2026-09-20 17:15:53 +08:00
|
|
|
|
semaphore = _async_limiter(max_concurrency)
|
2026-01-30 11:41:09 +08:00
|
|
|
|
|
|
|
|
|
|
async def limited_request_func(request_func_input, session, pbar):
|
|
|
|
|
|
async with semaphore:
|
|
|
|
|
|
return await request_func(request_func_input=request_func_input, session=session, pbar=pbar)
|
|
|
|
|
|
|
2026-08-11 23:23:46 +08:00
|
|
|
|
# Ported from upstream vLLM v0.27.0 (vllm/benchmarks/serve.py), which added
|
|
|
|
|
|
# the probe_request_rate background probe; this file is the patched copy of
|
|
|
|
|
|
# that module and tracks its upstream base.
|
|
|
|
|
|
probe_outputs: list[MixRequestFuncOutput] = []
|
|
|
|
|
|
probe_stop = asyncio.Event()
|
|
|
|
|
|
|
|
|
|
|
|
async def probe_loop():
|
|
|
|
|
|
probe_input = replace(
|
|
|
|
|
|
test_input,
|
|
|
|
|
|
prompt="Hi",
|
|
|
|
|
|
prompt_len=1,
|
|
|
|
|
|
output_len=1,
|
|
|
|
|
|
multi_modal_content=None,
|
|
|
|
|
|
chat_messages=None,
|
|
|
|
|
|
)
|
|
|
|
|
|
interval = 1 / probe_request_rate
|
|
|
|
|
|
while not probe_stop.is_set():
|
|
|
|
|
|
probe_outputs.append(await request_func(request_func_input=probe_input, session=session))
|
|
|
|
|
|
await asyncio.sleep(interval)
|
|
|
|
|
|
|
|
|
|
|
|
probe_task: asyncio.Task | None = None
|
|
|
|
|
|
if probe_request_rate > 0:
|
|
|
|
|
|
print(f"Probe request rate: {probe_request_rate} req/s")
|
|
|
|
|
|
probe_task = asyncio.create_task(probe_loop())
|
|
|
|
|
|
|
2026-08-25 23:32:58 +08:00
|
|
|
|
_prepare_omniinteract_batch(input_requests)
|
2026-01-30 11:41:09 +08:00
|
|
|
|
benchmark_start_time = time.perf_counter()
|
|
|
|
|
|
tasks: list[asyncio.Task] = []
|
|
|
|
|
|
|
|
|
|
|
|
rps_change_events = []
|
|
|
|
|
|
last_int_rps = -1
|
|
|
|
|
|
if ramp_up_strategy is not None and ramp_up_start_rps is not None:
|
|
|
|
|
|
last_int_rps = ramp_up_start_rps
|
|
|
|
|
|
rps_change_events.append(
|
|
|
|
|
|
{
|
|
|
|
|
|
"rps": last_int_rps,
|
|
|
|
|
|
"timestamp": datetime.now().isoformat(),
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
async for request, current_request_rate in get_request(
|
|
|
|
|
|
input_requests,
|
|
|
|
|
|
request_rate,
|
|
|
|
|
|
burstiness,
|
|
|
|
|
|
ramp_up_strategy,
|
|
|
|
|
|
ramp_up_start_rps,
|
|
|
|
|
|
ramp_up_end_rps,
|
2026-06-14 00:43:02 +08:00
|
|
|
|
self_timed,
|
2026-01-30 11:41:09 +08:00
|
|
|
|
):
|
|
|
|
|
|
if ramp_up_strategy is not None:
|
|
|
|
|
|
current_int_rps = int(current_request_rate)
|
|
|
|
|
|
if current_int_rps > last_int_rps:
|
|
|
|
|
|
timestamp = datetime.now().isoformat()
|
|
|
|
|
|
for rps_val in range(last_int_rps + 1, current_int_rps + 1):
|
|
|
|
|
|
rps_change_events.append({"rps": rps_val, "timestamp": timestamp})
|
|
|
|
|
|
last_int_rps = current_int_rps
|
|
|
|
|
|
prompt, prompt_len, output_len, mm_content, request_id = (
|
|
|
|
|
|
request.prompt,
|
|
|
|
|
|
request.prompt_len,
|
|
|
|
|
|
request.expected_output_len,
|
|
|
|
|
|
request.multi_modal_data,
|
|
|
|
|
|
request.request_id,
|
|
|
|
|
|
)
|
2026-06-30 19:41:06 +08:00
|
|
|
|
per_request_extra_body = _merge_overrides(extra_body, request.request_overrides)
|
2026-01-30 11:41:09 +08:00
|
|
|
|
req_model_id, req_model_name = model_id, model_name
|
2026-09-20 17:15:53 +08:00
|
|
|
|
if lora_iter is not None:
|
|
|
|
|
|
req_lora_module = next(lora_iter)
|
2026-01-30 11:41:09 +08:00
|
|
|
|
req_model_id, req_model_name = req_lora_module, req_lora_module
|
|
|
|
|
|
|
|
|
|
|
|
request_func_input = RequestFuncInput(
|
|
|
|
|
|
model=req_model_id,
|
|
|
|
|
|
model_name=req_model_name,
|
|
|
|
|
|
prompt=prompt,
|
|
|
|
|
|
api_url=api_url,
|
|
|
|
|
|
prompt_len=prompt_len,
|
|
|
|
|
|
output_len=output_len,
|
|
|
|
|
|
logprobs=logprobs,
|
|
|
|
|
|
multi_modal_content=mm_content,
|
|
|
|
|
|
ignore_eos=ignore_eos,
|
|
|
|
|
|
extra_headers=extra_headers,
|
2026-06-30 19:41:06 +08:00
|
|
|
|
extra_body=per_request_extra_body,
|
2026-01-30 11:41:09 +08:00
|
|
|
|
request_id=request_id,
|
2026-06-30 19:41:06 +08:00
|
|
|
|
chat_messages=request.chat_messages,
|
2026-01-30 11:41:09 +08:00
|
|
|
|
)
|
2026-09-20 17:15:53 +08:00
|
|
|
|
_attach_omni_chat_to_request_func_input(request, request_func_input)
|
2026-04-15 11:07:35 +08:00
|
|
|
|
_attach_seed_tts_to_request_func_input(request, request_func_input)
|
2026-08-25 23:32:58 +08:00
|
|
|
|
_attach_omniinteract_to_request_func_input(request, request_func_input)
|
2026-01-30 11:41:09 +08:00
|
|
|
|
tasks.append(
|
|
|
|
|
|
asyncio.create_task(limited_request_func(request_func_input=request_func_input, session=session, pbar=pbar))
|
|
|
|
|
|
)
|
|
|
|
|
|
outputs: list[MixRequestFuncOutput] = await asyncio.gather(*tasks)
|
|
|
|
|
|
|
2026-08-11 23:23:46 +08:00
|
|
|
|
if probe_task is not None:
|
|
|
|
|
|
probe_stop.set()
|
|
|
|
|
|
await probe_task
|
|
|
|
|
|
|
2026-01-30 11:41:09 +08:00
|
|
|
|
if pbar is not None:
|
|
|
|
|
|
pbar.close()
|
|
|
|
|
|
|
|
|
|
|
|
benchmark_duration = time.perf_counter() - benchmark_start_time
|
|
|
|
|
|
|
2026-08-25 23:32:58 +08:00
|
|
|
|
omniinteract_summary = _finalize_omniinteract_batch(input_requests, outputs)
|
2026-09-24 16:34:23 +08:00
|
|
|
|
omniinteract_evaluation = await _evaluate_omniinteract_batch(input_requests, outputs)
|
|
|
|
|
|
if omniinteract_summary is not None and omniinteract_evaluation is not None:
|
|
|
|
|
|
omniinteract_summary["accuracy"] = omniinteract_evaluation
|
2026-08-25 23:32:58 +08:00
|
|
|
|
|
2026-09-20 17:15:53 +08:00
|
|
|
|
metrics: Any
|
|
|
|
|
|
actual_output_lens: list[int] | int
|
2026-01-30 11:41:09 +08:00
|
|
|
|
if task_type == TaskType.GENERATION:
|
|
|
|
|
|
metrics, actual_output_lens = calculate_metrics(
|
|
|
|
|
|
input_requests=input_requests,
|
|
|
|
|
|
outputs=outputs,
|
|
|
|
|
|
dur_s=benchmark_duration,
|
|
|
|
|
|
tokenizer=tokenizer,
|
|
|
|
|
|
selected_percentiles=selected_percentiles,
|
|
|
|
|
|
goodput_config_dict=goodput_config_dict,
|
|
|
|
|
|
task_type=task_type,
|
|
|
|
|
|
selected_percentile_metrics=selected_percentile_metrics,
|
|
|
|
|
|
max_concurrency=max_concurrency,
|
|
|
|
|
|
request_rate=request_rate,
|
|
|
|
|
|
benchmark_duration=benchmark_duration,
|
2026-06-04 21:43:35 +08:00
|
|
|
|
print_stage=_PRINT_STAGE,
|
2026-01-30 11:41:09 +08:00
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
metrics = calculate_metrics_for_embeddings(
|
|
|
|
|
|
outputs=outputs,
|
|
|
|
|
|
dur_s=benchmark_duration,
|
|
|
|
|
|
selected_percentiles=selected_percentiles,
|
|
|
|
|
|
)
|
|
|
|
|
|
actual_output_lens = 0
|
|
|
|
|
|
|
|
|
|
|
|
if isinstance(metrics, MultiModalsBenchmarkMetrics):
|
2026-09-20 17:15:53 +08:00
|
|
|
|
# ``make_dataclass`` types this class as ``type``, so isinstance()
|
|
|
|
|
|
# narrows to ``object`` and attribute access fails. Keep the runtime
|
|
|
|
|
|
# check and read fields through ``Any``.
|
|
|
|
|
|
mm_metrics: Any = metrics
|
2026-08-25 23:32:58 +08:00
|
|
|
|
|
|
|
|
|
|
def measured_ttft(output: RequestFuncOutput) -> float | None:
|
|
|
|
|
|
session_metrics = getattr(output, "duplex_session_metrics", None)
|
2026-09-15 06:25:59 +08:00
|
|
|
|
if isinstance(session_metrics, dict) and session_metrics.get("ttft_ms") is None:
|
2026-08-25 23:32:58 +08:00
|
|
|
|
return None
|
|
|
|
|
|
return output.ttft
|
|
|
|
|
|
|
2026-01-30 11:41:09 +08:00
|
|
|
|
result = {
|
|
|
|
|
|
"duration": benchmark_duration,
|
2026-09-20 17:15:53 +08:00
|
|
|
|
"completed": mm_metrics.completed,
|
|
|
|
|
|
"failed": mm_metrics.failed,
|
|
|
|
|
|
"total_input_tokens": mm_metrics.total_input,
|
|
|
|
|
|
"total_output_tokens": mm_metrics.total_output,
|
|
|
|
|
|
"request_throughput": mm_metrics.request_throughput,
|
|
|
|
|
|
"request_goodput": mm_metrics.request_goodput if goodput_config_dict else None,
|
|
|
|
|
|
"output_throughput": mm_metrics.output_throughput,
|
|
|
|
|
|
"total_token_throughput": mm_metrics.total_token_throughput,
|
|
|
|
|
|
defs.TOTAL_AUDIO_DURATION_S: getattr(mm_metrics, defs.TOTAL_AUDIO_DURATION_S),
|
|
|
|
|
|
defs.TOTAL_AUDIO_FRAMES: getattr(mm_metrics, defs.TOTAL_AUDIO_FRAMES),
|
|
|
|
|
|
defs.AUDIO_THROUGHPUT: getattr(mm_metrics, defs.AUDIO_THROUGHPUT),
|
|
|
|
|
|
defs.TOTAL_IMAGES: getattr(mm_metrics, defs.TOTAL_IMAGES),
|
|
|
|
|
|
defs.IMAGE_THROUGHPUT: getattr(mm_metrics, defs.IMAGE_THROUGHPUT),
|
|
|
|
|
|
defs.AVERAGE_PIXELS_PER_IMAGE: getattr(mm_metrics, defs.AVERAGE_PIXELS_PER_IMAGE),
|
|
|
|
|
|
defs.MEAN_DENOISE_STEP_LATENCY_MS: getattr(mm_metrics, defs.MEAN_DENOISE_STEP_LATENCY_MS),
|
|
|
|
|
|
defs.TOTAL_VIDEO_DURATION_S: getattr(mm_metrics, defs.TOTAL_VIDEO_DURATION_S),
|
|
|
|
|
|
defs.TOTAL_VIDEO_FRAMES: getattr(mm_metrics, defs.TOTAL_VIDEO_FRAMES),
|
|
|
|
|
|
defs.VIDEO_THROUGHPUT: getattr(mm_metrics, defs.VIDEO_THROUGHPUT),
|
|
|
|
|
|
defs.MEAN_VIDEO_RTF: getattr(mm_metrics, defs.MEAN_VIDEO_RTF),
|
|
|
|
|
|
defs.MEDIAN_VIDEO_RTF: getattr(mm_metrics, defs.MEDIAN_VIDEO_RTF),
|
|
|
|
|
|
defs.PERCENTILES_VIDEO_RTF: getattr(mm_metrics, defs.PERCENTILES_VIDEO_RTF),
|
|
|
|
|
|
defs.MEAN_VIDEO_GENERATION_MS: getattr(mm_metrics, defs.MEAN_VIDEO_GENERATION_MS),
|
|
|
|
|
|
defs.MEDIAN_VIDEO_GENERATION_MS: getattr(mm_metrics, defs.MEDIAN_VIDEO_GENERATION_MS),
|
|
|
|
|
|
defs.PERCENTILES_VIDEO_GENERATION_MS: getattr(mm_metrics, defs.PERCENTILES_VIDEO_GENERATION_MS),
|
|
|
|
|
|
defs.MEAN_PEAK_MEMORY_MB: getattr(mm_metrics, defs.MEAN_PEAK_MEMORY_MB),
|
|
|
|
|
|
defs.MEDIAN_PEAK_MEMORY_MB: getattr(mm_metrics, defs.MEDIAN_PEAK_MEMORY_MB),
|
|
|
|
|
|
defs.PERCENTILES_PEAK_MEMORY_MB: getattr(mm_metrics, defs.PERCENTILES_PEAK_MEMORY_MB),
|
2026-09-22 10:16:39 +08:00
|
|
|
|
defs.STAGE_DURATIONS_MEAN: getattr(mm_metrics, defs.STAGE_DURATIONS_MEAN) or {},
|
|
|
|
|
|
defs.STAGE_DURATIONS_P50: getattr(mm_metrics, defs.STAGE_DURATIONS_P50) or {},
|
|
|
|
|
|
defs.STAGE_DURATIONS_P99: getattr(mm_metrics, defs.STAGE_DURATIONS_P99) or {},
|
2026-01-30 11:41:09 +08:00
|
|
|
|
"input_lens": [output.prompt_len for output in outputs],
|
2026-06-14 00:43:02 +08:00
|
|
|
|
"start_times": [output.start_time for output in outputs],
|
2026-01-30 11:41:09 +08:00
|
|
|
|
"output_lens": actual_output_lens,
|
2026-08-25 23:32:58 +08:00
|
|
|
|
"ttfts": [measured_ttft(output) for output in outputs],
|
2026-01-30 11:41:09 +08:00
|
|
|
|
"itls": [output.itl for output in outputs],
|
|
|
|
|
|
"generated_texts": [output.generated_text for output in outputs],
|
|
|
|
|
|
"errors": [output.error for output in outputs],
|
2026-09-20 17:15:53 +08:00
|
|
|
|
"max_output_tokens_per_s": mm_metrics.max_output_tokens_per_s,
|
|
|
|
|
|
"max_concurrent_requests": mm_metrics.max_concurrent_requests,
|
|
|
|
|
|
"rtfx": mm_metrics.rtfx,
|
2026-01-30 11:41:09 +08:00
|
|
|
|
}
|
2026-09-16 23:39:01 +08:00
|
|
|
|
for sample_count in (
|
|
|
|
|
|
"num_ttft_samples",
|
|
|
|
|
|
"num_tpot_samples",
|
|
|
|
|
|
"num_itl_samples",
|
|
|
|
|
|
"num_audio_ttfp_samples",
|
|
|
|
|
|
"num_audio_rtf_samples",
|
|
|
|
|
|
):
|
|
|
|
|
|
result[sample_count] = getattr(metrics, sample_count)
|
2026-01-30 11:41:09 +08:00
|
|
|
|
else:
|
|
|
|
|
|
result = {
|
|
|
|
|
|
"duration": benchmark_duration,
|
|
|
|
|
|
"completed": metrics.completed,
|
|
|
|
|
|
"total_input_tokens": metrics.total_input,
|
2026-09-10 12:08:28 +08:00
|
|
|
|
"total_input_sequences": metrics.total_input_sequences,
|
2026-01-30 11:41:09 +08:00
|
|
|
|
"request_throughput": metrics.request_throughput,
|
2026-09-10 12:08:28 +08:00
|
|
|
|
"input_sequence_throughput": metrics.input_sequence_throughput,
|
2026-01-30 11:41:09 +08:00
|
|
|
|
"total_token_throughput": metrics.total_token_throughput,
|
|
|
|
|
|
"input_lens": [output.prompt_len for output in outputs],
|
|
|
|
|
|
"errors": [output.error for output in outputs],
|
|
|
|
|
|
}
|
2026-09-24 23:56:53 +08:00
|
|
|
|
# Preserve request order, including missing snapshots, so CI can verify
|
|
|
|
|
|
# fixed stage workloads without parsing logs or storing audio payloads.
|
|
|
|
|
|
request_stage_metrics = [
|
|
|
|
|
|
_compact_request_stage_metrics(getattr(output, "stage_metrics", None)) for output in outputs
|
|
|
|
|
|
]
|
|
|
|
|
|
if any(request_stage_metrics):
|
|
|
|
|
|
result["request_stage_metrics"] = request_stage_metrics
|
|
|
|
|
|
|
2026-08-11 23:23:46 +08:00
|
|
|
|
# Plain-vLLM backends (e.g. the vLLM-text perf config) return upstream
|
|
|
|
|
|
# RequestFuncOutput objects without the Mix duplex fields; read them
|
|
|
|
|
|
# tolerantly or the whole benchmark result is discarded ("fallback to
|
|
|
|
|
|
# template", completed=0) after every request already succeeded.
|
|
|
|
|
|
duplex_request_metrics = [
|
|
|
|
|
|
metric for output in outputs for metric in (getattr(output, "duplex_request_metrics", None) or [])
|
|
|
|
|
|
]
|
2026-08-11 14:53:49 +08:00
|
|
|
|
if duplex_request_metrics:
|
|
|
|
|
|
result["duplex_request_metrics"] = duplex_request_metrics
|
|
|
|
|
|
duplex_session_metrics = [
|
2026-08-11 23:23:46 +08:00
|
|
|
|
session_metrics
|
|
|
|
|
|
for output in outputs
|
|
|
|
|
|
if (session_metrics := getattr(output, "duplex_session_metrics", None)) is not None
|
2026-08-11 14:53:49 +08:00
|
|
|
|
]
|
|
|
|
|
|
if duplex_session_metrics:
|
|
|
|
|
|
result["duplex_session_metrics"] = duplex_session_metrics
|
2026-09-15 06:25:59 +08:00
|
|
|
|
from vllm_omni.clients.duplex import distribution_summary
|
|
|
|
|
|
|
|
|
|
|
|
for session_key, result_key, digits in (
|
|
|
|
|
|
("stream_ttft_ms", "duplex_stream_ttft_ms", 3),
|
|
|
|
|
|
("stream_ttfp_ms", "duplex_stream_ttfp_ms", 3),
|
|
|
|
|
|
("stream_rtf", "duplex_stream_rtf", 6),
|
|
|
|
|
|
):
|
|
|
|
|
|
values = [
|
|
|
|
|
|
float(value)
|
|
|
|
|
|
for metric in duplex_session_metrics
|
|
|
|
|
|
if isinstance((value := metric.get(session_key)), int | float)
|
|
|
|
|
|
and not isinstance(value, bool)
|
|
|
|
|
|
and np.isfinite(value)
|
|
|
|
|
|
and value >= 0
|
|
|
|
|
|
]
|
|
|
|
|
|
summary = distribution_summary(values, digits=digits)
|
|
|
|
|
|
if summary is not None:
|
|
|
|
|
|
result[result_key] = summary
|
2026-08-25 23:32:58 +08:00
|
|
|
|
if omniinteract_summary is not None:
|
|
|
|
|
|
result["omniinteract"] = omniinteract_summary
|
2026-01-30 11:41:09 +08:00
|
|
|
|
|
2026-04-15 11:07:35 +08:00
|
|
|
|
from vllm_omni.benchmarks.data_modules.daily_omni_eval import (
|
|
|
|
|
|
compute_daily_omni_accuracy_metrics,
|
|
|
|
|
|
print_daily_omni_accuracy_summary,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
_save_items = os.environ.get("DAILY_OMNI_SAVE_EVAL_ITEMS", "").lower() in (
|
|
|
|
|
|
"1",
|
|
|
|
|
|
"true",
|
|
|
|
|
|
"yes",
|
|
|
|
|
|
)
|
|
|
|
|
|
_daily_acc = compute_daily_omni_accuracy_metrics(input_requests, outputs, include_per_item=_save_items)
|
|
|
|
|
|
if _daily_acc is not None:
|
|
|
|
|
|
result.update(_daily_acc)
|
|
|
|
|
|
print_daily_omni_accuracy_summary(_daily_acc)
|
|
|
|
|
|
|
2026-09-20 17:15:53 +08:00
|
|
|
|
from vllm_omni.benchmarks.data_modules.videomme_eval import (
|
|
|
|
|
|
compute_videomme_accuracy_metrics,
|
|
|
|
|
|
print_videomme_accuracy_summary,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
_save_vm = os.environ.get("VIDEOMME_SAVE_EVAL_ITEMS", "").lower() in (
|
|
|
|
|
|
"1",
|
|
|
|
|
|
"true",
|
|
|
|
|
|
"yes",
|
|
|
|
|
|
)
|
|
|
|
|
|
_vm_acc = compute_videomme_accuracy_metrics(input_requests, outputs, include_per_item=_save_vm)
|
|
|
|
|
|
if _vm_acc is not None:
|
|
|
|
|
|
result.update(_vm_acc)
|
|
|
|
|
|
print_videomme_accuracy_summary(_vm_acc)
|
|
|
|
|
|
|
2026-04-15 11:07:35 +08:00
|
|
|
|
if _seed_tts_capture_pcm_for_wer():
|
|
|
|
|
|
from vllm_omni.benchmarks.data_modules.seed_tts_eval import (
|
|
|
|
|
|
compute_seed_tts_wer_metrics,
|
|
|
|
|
|
print_seed_tts_wer_summary,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
_save_wer = os.environ.get("SEED_TTS_WER_SAVE_ITEMS", "").lower() in (
|
|
|
|
|
|
"1",
|
|
|
|
|
|
"true",
|
|
|
|
|
|
"yes",
|
|
|
|
|
|
)
|
|
|
|
|
|
_wer_m = compute_seed_tts_wer_metrics(input_requests, outputs, include_per_item=_save_wer)
|
|
|
|
|
|
if _wer_m is not None:
|
|
|
|
|
|
result.update(_wer_m)
|
|
|
|
|
|
print_seed_tts_wer_summary(_wer_m)
|
|
|
|
|
|
|
2026-01-30 11:41:09 +08:00
|
|
|
|
if rps_change_events:
|
|
|
|
|
|
result["rps_change_events"] = rps_change_events
|
|
|
|
|
|
|
2026-06-04 21:43:35 +08:00
|
|
|
|
result_percentile_metrics: list[str] = []
|
|
|
|
|
|
if "ttft" in selected_percentile_metrics:
|
|
|
|
|
|
result_percentile_metrics.append("ttft")
|
|
|
|
|
|
if "tpot" in selected_percentile_metrics or "tpop" in selected_percentile_metrics:
|
|
|
|
|
|
result_percentile_metrics.append("tpot")
|
|
|
|
|
|
if "itl" in selected_percentile_metrics:
|
|
|
|
|
|
result_percentile_metrics.append("itl")
|
|
|
|
|
|
if "e2el" in selected_percentile_metrics:
|
|
|
|
|
|
result_percentile_metrics.append("e2el")
|
|
|
|
|
|
for metric in selected_percentile_metrics:
|
|
|
|
|
|
if metric.startswith("audio") and metric not in result_percentile_metrics:
|
|
|
|
|
|
result_percentile_metrics.append(metric)
|
|
|
|
|
|
|
2026-01-30 11:41:09 +08:00
|
|
|
|
def process_one_metric(
|
|
|
|
|
|
# E.g., "ttft"
|
|
|
|
|
|
metric_attribute_name: str,
|
|
|
|
|
|
):
|
|
|
|
|
|
# This function prints and adds statistics of the specified
|
|
|
|
|
|
# metric.
|
2026-06-04 21:43:35 +08:00
|
|
|
|
if metric_attribute_name not in result_percentile_metrics:
|
2026-01-30 11:41:09 +08:00
|
|
|
|
return
|
2026-08-25 23:32:58 +08:00
|
|
|
|
if not has_metric_samples(metrics, metric_attribute_name):
|
|
|
|
|
|
return
|
2026-05-18 05:18:18 -04:00
|
|
|
|
# No text tokens generated (e.g. pure TTS speech endpoint): per-token
|
|
|
|
|
|
# latency metrics (ttft/tpot/itl) are undefined, so skip them.
|
2026-05-20 01:08:56 -04:00
|
|
|
|
is_text_token_metric = not (metric_attribute_name == "e2el" or metric_attribute_name.startswith("audio"))
|
2026-05-18 05:18:18 -04:00
|
|
|
|
if is_text_token_metric and getattr(metrics, "total_output", 0) == 0:
|
|
|
|
|
|
return
|
2026-06-04 21:43:35 +08:00
|
|
|
|
is_audio_rtf = metric_attribute_name == defs.AUDIO_RTF
|
|
|
|
|
|
is_audio_duration_or_underrun = metric_attribute_name in (defs.AUDIO_DURATION, defs.AUDIO_UNDERRUN)
|
2026-01-30 11:41:09 +08:00
|
|
|
|
|
2026-02-04 14:07:46 +08:00
|
|
|
|
suffix = "_ms"
|
2026-05-20 01:08:56 -04:00
|
|
|
|
if is_audio_duration_or_underrun:
|
2026-02-04 14:07:46 +08:00
|
|
|
|
suffix = "_s"
|
|
|
|
|
|
elif is_audio_rtf:
|
|
|
|
|
|
suffix = ""
|
|
|
|
|
|
mean_attr_name = f"mean_{metric_attribute_name}{suffix}"
|
|
|
|
|
|
mean_value = getattr(metrics, mean_attr_name, 0.0)
|
|
|
|
|
|
result[mean_attr_name] = mean_value
|
|
|
|
|
|
|
|
|
|
|
|
median_attr_name = f"median_{metric_attribute_name}{suffix}"
|
|
|
|
|
|
median_value = getattr(metrics, median_attr_name, 0.0)
|
|
|
|
|
|
result[median_attr_name] = median_value
|
2026-05-20 01:08:56 -04:00
|
|
|
|
for p, value in getattr(metrics, f"percentiles_{metric_attribute_name}{suffix}", None) or []:
|
2026-01-30 11:41:09 +08:00
|
|
|
|
p_word = str(int(p)) if int(p) == p else str(p)
|
|
|
|
|
|
result[f"p{p_word}_{metric_attribute_name}{suffix}"] = value
|
|
|
|
|
|
|
|
|
|
|
|
if task_type == TaskType.GENERATION:
|
2026-06-04 21:43:35 +08:00
|
|
|
|
for metric in result_percentile_metrics:
|
2026-01-30 11:41:09 +08:00
|
|
|
|
process_one_metric(metric)
|
|
|
|
|
|
else:
|
2026-06-04 21:43:35 +08:00
|
|
|
|
result_percentile_metrics.append("e2el")
|
2026-01-30 11:41:09 +08:00
|
|
|
|
process_one_metric("e2el")
|
|
|
|
|
|
|
|
|
|
|
|
if profile:
|
|
|
|
|
|
print("Stopping profiler...")
|
|
|
|
|
|
profile_input = RequestFuncInput(
|
|
|
|
|
|
model=model_id,
|
|
|
|
|
|
prompt=test_prompt,
|
|
|
|
|
|
api_url=base_url + "/stop_profile",
|
|
|
|
|
|
prompt_len=test_prompt_len,
|
|
|
|
|
|
output_len=test_output_len,
|
|
|
|
|
|
logprobs=logprobs,
|
2026-06-30 19:41:06 +08:00
|
|
|
|
extra_body=test_extra_body,
|
|
|
|
|
|
chat_messages=test_chat_messages,
|
2026-01-30 11:41:09 +08:00
|
|
|
|
)
|
|
|
|
|
|
profile_output = await request_func(request_func_input=profile_input, session=session)
|
|
|
|
|
|
if profile_output.success:
|
|
|
|
|
|
print("Profiler stopped")
|
|
|
|
|
|
|
|
|
|
|
|
await session.close()
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
serve.benchmark = benchmark
|