Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions examples/custom_encoder/launch/agg_custom_remote.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#!/bin/bash
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Serve-test entry point for the remote CustomEncoder topology.
#
# The serve harness launches "<directory>/launch/<script>" and hands each
# topology dynamic ports as DYN_HTTP_PORT and DYN_SYSTEM_PORT<N>. The remote
# example instead names its two worker ports DYN_GENERATOR_SYSTEM_PORT and
# DYN_ORCHESTRATOR_SYSTEM_PORT, so map them here; without the mapping, parallel
# topologies would collide on the 8081/8082 defaults.

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

export DYN_GENERATOR_SYSTEM_PORT="${DYN_GENERATOR_SYSTEM_PORT:-${DYN_SYSTEM_PORT1:-8081}}"
export DYN_ORCHESTRATOR_SYSTEM_PORT="${DYN_ORCHESTRATOR_SYSTEM_PORT:-${DYN_SYSTEM_PORT2:-8082}}"

exec "$SCRIPT_DIR/../remote/launch.sh" "$@"
33 changes: 33 additions & 0 deletions tests/serve/multimodal_profiles/vllm.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
make_mixed_image_video_payload,
make_qwen35_custom_encoder_multi_image_payload,
make_qwen35_custom_encoder_payload,
make_remote_custom_encoder_payload,
make_remote_custom_encoder_text_only_payload,
make_video_payload,
)
from tests.utils.payload_builder import (
Expand Down Expand Up @@ -86,6 +88,9 @@
# not examples/backends/vllm — the TopologyConfig sets `directory` to match.
"agg_custom": "agg_custom.sh",
"agg_custom_qwen3_5": "agg_qwen3_5_native.sh",
# Remote CustomEncoder: the encoder runs in an application orchestrator
# worker and hands prepared embeddings to a stock aggregated generator.
"agg_custom_remote": "agg_custom_remote.sh",
}

VLLM_MULTIMODAL_PROFILES: list[MultimodalModelProfile] = [
Expand Down Expand Up @@ -754,6 +759,34 @@
},
tests=[MmCase(payload=make_custom_encoder_payload())],
),
# Same encoder and phrase as `agg_custom`, but the encoder runs in
# an application orchestrator worker that hands prepared embeddings
# to a stock aggregated generator over the request plane. Matching
# "42" on both topologies is what shows the handoff preserves the
# in-process semantics.
"agg_custom_remote": TopologyConfig(
marks=[pytest.mark.post_merge],
timeout_s=300,
directory=os.path.join(WORKSPACE_DIR, "examples/custom_encoder"),
env={
# The harness addresses the deployment by the profile's model id,
# including its text-only readiness probe, so publish under that
# name instead of the example's default "remote-custom-encoder".
"DYN_SERVED_MODEL_NAME": "Qwen/Qwen2.5-1.5B-Instruct",
"DYN_SERVICE_NAME": "remote-custom-encoder",
"DYN_GENERATOR_GPU": "0",
"PYTHONPATH": str(WORKSPACE_DIR),
},
tests=[
# A turn with no media must still be served: the orchestrator
# forwards it to the generator instead of requiring an image.
MmCase(
suffix="text_only",
payload=make_remote_custom_encoder_text_only_payload(),
),
MmCase(payload=make_remote_custom_encoder_payload()),
],
),
},
),
]
79 changes: 79 additions & 0 deletions tests/utils/multimodal.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import base64
import os
import re
from collections.abc import Mapping
from copy import deepcopy
from dataclasses import dataclass, field
from pathlib import Path
Expand Down Expand Up @@ -487,6 +488,84 @@ def make_custom_encoder_payload() -> ChatPayload:
)


class RemoteCustomEncoderChatPayload(ChatPayload):
"""Validate generation plus the orchestrator's dummy classifier label."""

def validate(self, response: Any, content: str) -> None:
super().validate(response, content)

result = response.json()
nvext = result.get("nvext")
assert isinstance(nvext, Mapping), f"Expected nvext object, got {nvext!r}"
engine_data = nvext.get("engine_data")
assert isinstance(
engine_data, Mapping
), f"Expected nvext.engine_data object, got {engine_data!r}"
classifier_label = engine_data.get("classifier_label")
assert classifier_label in {"class_a", "class_b"}, (
"Expected nvext.engine_data.classifier_label to contain a dummy class, "
f"got {classifier_label!r}"
)


def make_remote_custom_encoder_payload() -> RemoteCustomEncoderChatPayload:
"""Semantic check for the remote CustomEncoder topology.

Same phrase-splice contract as :func:`make_custom_encoder_payload`, but the
encoder runs in the orchestrator worker and the embeddings reach the stock
aggregated generator over the request plane. ``expected_log`` therefore
asserts the orchestrator's own encoder line rather than the decoder-side
``Loaded CustomEncoder``, which only the in-process topology emits.
"""
return RemoteCustomEncoderChatPayload(
body={
"messages": [
{
"role": "user",
"content": [
{
"type": "text",
"text": (
"Based on The Hitchhiker's Guide to the Galaxy, "
"The Answer to"
),
},
{
"type": "image_url",
"image_url": {"url": MULTIMODAL_IMG_URL},
},
{"type": "text", "text": " is?"},
],
}
],
"max_tokens": 32,
"stream": False,
"logprobs": False,
"temperature": 0.0,
"nvext": {"extra_fields": ["engine_data"]},
},
repeat_count=1,
expected_response=["42"],
expected_log=[r"HitchhikersVisionEncoder\] ready"],
)


def make_remote_custom_encoder_text_only_payload() -> ChatPayload:
"""Text-only turn against the remote CustomEncoder topology.

The orchestrator must forward a request that carries no media straight to
the generator. Asserting a served answer here keeps a conversation's
non-image turns from regressing back into a request-level rejection.
"""
return chat_payload(
"Based on The Hitchhiker's Guide to the Galaxy, The Answer to is?",
repeat_count=1,
expected_response=["42", "answer", "life", "Adams"],
max_tokens=32,
temperature=0.0,
)


def make_qwen35_custom_encoder_payload() -> ChatPayload:
"""Single-image semantic check for the native Qwen3.5 custom encoder."""
return Base64LazyChatPayload(
Expand Down
46 changes: 46 additions & 0 deletions tests/utils/test_multimodal.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@
get_multimodal_test_image_bytes,
)
from tests.utils.multimodal import (
RemoteCustomEncoderChatPayload,
UuidPassthroughChatPayload,
make_mixed_image_video_payload,
make_qwen35_custom_encoder_multi_image_payload,
make_remote_custom_encoder_payload,
)

pytestmark = [
Expand All @@ -27,6 +29,14 @@ def _image_part(body: dict) -> dict:
return body["messages"][0]["content"][1]


class _Response:
def __init__(self, data: dict) -> None:
self._data = data

def json(self) -> dict:
return self._data


def test_uuid_passthrough_payload_sends_fill_then_uuid_only() -> None:
payload = UuidPassthroughChatPayload(expected_response=["green"])

Expand Down Expand Up @@ -89,6 +99,42 @@ def test_qwen35_multi_image_payload_is_order_sensitive() -> None:
]


def test_remote_custom_encoder_payload_validates_classifier_label() -> None:
payload = make_remote_custom_encoder_payload()

assert isinstance(payload, RemoteCustomEncoderChatPayload)
assert payload.body["nvext"] == {"extra_fields": ["engine_data"]}
payload.validate(
_Response(
{
"nvext": {
"engine_data": {
"classifier_label": "class_a",
}
}
}
),
"42",
)


@pytest.mark.parametrize(
"data",
[
{},
{"nvext": {}},
{"nvext": {"engine_data": {"classifier_label": "unexpected"}}},
],
)
def test_remote_custom_encoder_payload_rejects_missing_or_unknown_label(
data: dict,
) -> None:
payload = make_remote_custom_encoder_payload()

with pytest.raises(AssertionError):
payload.validate(_Response(data), "42")


def test_mixed_image_video_payload_requires_video_context() -> None:
payload = make_mixed_image_video_payload(["triangle"], frontend_decoding=True)
content = payload.body["messages"][0]["content"]
Expand Down
Loading