Skip to content

Commit 0268a16

Browse files
authored
[CICD]: Add H20 Qwen3.6 E2E cases (#321)
Additional NVIDIA H20 E2E testing: - Qwen3.6-27B TP2 - Qwen3.6-35B-A3B TP2 - Covers text and image requests - Models uniformly referenced at /data/models/Qwen/ Manually verified on H20 machine using CUDA CI container, both cases passed. CUDA CI is configured to use the `vllm-plugin-ci-h20` runner label.
1 parent d1327ae commit 0268a16

10 files changed

Lines changed: 177 additions & 10 deletions

File tree

.github/configs/cuda.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ runner_labels:
2929
- gpu-8
3030
- cuda130
3131

32+
# Device-specific runner labels override the platform defaults for E2E jobs.
33+
device_runner_labels:
34+
h20:
35+
- vllm-plugin-ci-h20
36+
3237
# Runner labels for this hardware (Platform hosted)
3338
# runner_labels:
3439
# - ci-vllm-plugin-fl

.github/scripts/load_config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ def load_platform_config(platform: str) -> dict:
4545

4646
# Ensure the platform key is present
4747
config.setdefault("platform", platform)
48+
config.setdefault("device_runner_labels", {})
4849
return config
4950

5051

.github/workflows/_platform_test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ jobs:
140140
device: ${{ matrix.test.device }}
141141
cases: ${{ matrix.test.cases }}
142142
ci_image: ${{ matrix.test.image || fromJson(needs.setup.outputs.config).ci_image }}
143-
runner_labels: ${{ toJson(fromJson(needs.setup.outputs.config).runner_labels) }}
143+
runner_labels: ${{ toJson(fromJson(needs.setup.outputs.config).device_runner_labels[matrix.test.device] || fromJson(needs.setup.outputs.config).runner_labels) }}
144144
container_volumes: ${{ toJson(fromJson(needs.setup.outputs.config).container_volumes) }}
145145
container_options: ${{ fromJson(needs.setup.outputs.config).container_options }}
146146
timeout: ${{ matrix.test.timeout || 60 }}

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ test = [
6565
"numpy",
6666
"requests",
6767
"openai",
68+
"pillow",
6869
"decorator",
6970
"vllm[audio]==0.20.2",
7071
"modelscope>=1.18.1",

tests/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,7 @@ generate:
239239
| `serve.endpoints` | list | No | Endpoints to test: `completion`, `chat` |
240240
| `serve.completion_prompt` | str | No | Prompt for `/v1/completions` |
241241
| `serve.chat_messages` | list | No | Messages for `/v1/chat/completions` |
242+
| `serve.chat_cases` | list | No | Named chat requests run against one server; `expected` validates required response substrings and `generated_image: true` creates the local image fixture |
242243
| `serve.max_tokens` | int | No | Max tokens for serving requests (default: 50) |
243244
| `serve.api_key` | str | No | API key for authenticated endpoints |
244245
| `serve.extra_engine` | dict | No | Engine param overrides for serving only |

tests/e2e_tests/serving/test_serving_smoke.py

Lines changed: 67 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@
1515
controlled by the ``serve.stream`` flag in the model YAML.
1616
"""
1717

18+
import base64
1819
import os
20+
from io import BytesIO
1921

2022
import pytest
2123
import requests
@@ -138,20 +140,75 @@ def _run_chat(base_url: str, headers: dict) -> None:
138140
Otherwise, uses a plain requests POST.
139141
"""
140142
serve = _CFG.serve
141-
messages = serve.chat_messages or [{"role": "user", "content": "Hello"}]
142-
143-
if serve.stream:
144-
_run_chat_streaming(base_url, serve, messages)
145-
else:
146-
_run_chat_non_streaming(base_url, headers, serve, messages)
143+
chat_cases = serve.chat_cases or [
144+
{
145+
"name": "default",
146+
"messages": serve.chat_messages or [{"role": "user", "content": "Hello"}],
147+
}
148+
]
149+
150+
for chat_case in chat_cases:
151+
case_name = chat_case.get("name", "unnamed")
152+
if chat_case.get("generated_image"):
153+
messages = _generated_image_messages(
154+
chat_case.get("prompt", "Describe this image.")
155+
)
156+
else:
157+
messages = chat_case.get("messages", [])
158+
159+
assert messages, f"Chat case '{case_name}' has no messages"
160+
print(f"\nRunning chat case: {case_name}")
161+
if serve.stream:
162+
response_text = _run_chat_streaming(base_url, serve, messages)
163+
else:
164+
response_text = _run_chat_non_streaming(base_url, headers, serve, messages)
165+
166+
expected = chat_case.get("expected")
167+
if expected:
168+
expected_values = expected if isinstance(expected, list) else [expected]
169+
normalized_response = response_text.casefold()
170+
missing = [
171+
value
172+
for value in expected_values
173+
if value.casefold() not in normalized_response
174+
]
175+
is_correct = not missing
176+
print(f"Answer validation: {'CORRECT' if is_correct else 'INCORRECT'}")
177+
assert is_correct, f"Chat case '{case_name}' is missing expected: {missing}"
178+
179+
180+
def _generated_image_messages(prompt: str) -> list[dict]:
181+
"""Create the local image payload used by the Qwen multimodal smoke case."""
182+
from PIL import Image, ImageDraw
183+
184+
image = Image.new("RGB", (300, 200), color="white")
185+
draw = ImageDraw.Draw(image)
186+
draw.rectangle((50, 50, 250, 150), fill="blue")
187+
draw.text((90, 80), "Hello VLM", fill="yellow")
188+
189+
buffer = BytesIO()
190+
image.save(buffer, format="JPEG")
191+
encoded = base64.b64encode(buffer.getvalue()).decode("utf-8")
192+
return [
193+
{
194+
"role": "user",
195+
"content": [
196+
{
197+
"type": "image_url",
198+
"image_url": {"url": f"data:image/jpeg;base64,{encoded}"},
199+
},
200+
{"type": "text", "text": prompt},
201+
],
202+
}
203+
]
147204

148205

149206
def _run_chat_non_streaming(
150207
base_url: str,
151208
headers: dict,
152209
serve,
153210
messages: list[dict],
154-
) -> None:
211+
) -> str:
155212
"""Non-streaming chat completions via raw requests."""
156213
payload: dict = {
157214
"model": _REQUEST_MODEL,
@@ -177,13 +234,14 @@ def _run_chat_non_streaming(
177234
content = data["choices"][0]["message"]["content"]
178235
assert len(content.strip()) > 0, "Assistant message is empty"
179236
print(f"\nResponse: {content}")
237+
return content
180238

181239

182240
def _run_chat_streaming(
183241
base_url: str,
184242
serve,
185243
messages: list[dict],
186-
) -> None:
244+
) -> str:
187245
"""Streaming chat completions via OpenAI SDK."""
188246
import httpx
189247
from openai import OpenAI
@@ -214,6 +272,7 @@ def _run_chat_streaming(
214272

215273
assert len(text.strip()) > 0, "Streaming response is empty"
216274
print(f"\nStreaming response: {text}")
275+
return text
217276

218277

219278
def _run_embedding(base_url: str, headers: dict) -> None:
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Copyright 2026 FlagOS Contributors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
# Qwen3.6-27B 262K serving configuration (TP=2).
16+
17+
llm:
18+
model: "/data/models/Qwen/Qwen3.6-27B"
19+
tensor_parallel_size: 2
20+
pipeline_parallel_size: 1
21+
max_model_len: 262144
22+
trust_remote_code: false
23+
24+
serve:
25+
served_model_name: "qwen"
26+
startup_retries: 120
27+
endpoints: ["chat"]
28+
stream: true
29+
max_tokens: 1024
30+
sampling:
31+
temperature: 0.0
32+
chat_cases:
33+
- name: text
34+
messages:
35+
- role: "user"
36+
content: "The capital of France is"
37+
expected: "Paris"
38+
- name: image
39+
generated_image: true
40+
prompt: "Describe all visible text, colors, and shapes in English."
41+
expected:
42+
- "Hello VLM"
43+
- "blue rectangle"
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Copyright 2026 FlagOS Contributors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
# Qwen3.6-35B-A3B 262K serving configuration (TP=2).
16+
17+
llm:
18+
model: "/data/models/Qwen/Qwen3.6-35B-A3B"
19+
tensor_parallel_size: 2
20+
pipeline_parallel_size: 1
21+
max_model_len: 262144
22+
trust_remote_code: false
23+
24+
serve:
25+
served_model_name: "qwen"
26+
startup_retries: 120
27+
endpoints: ["chat"]
28+
stream: true
29+
max_tokens: 1024
30+
sampling:
31+
temperature: 0.0
32+
chat_cases:
33+
- name: text
34+
messages:
35+
- role: "user"
36+
content: "The capital of France is"
37+
expected: "Paris"
38+
- name: image
39+
generated_image: true
40+
prompt: "Describe all visible text, colors, and shapes in English."
41+
expected:
42+
- "Hello VLM"
43+
- "blue rectangle"

tests/platforms/cuda.yaml

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
# CUDA-Based Platform Configuration
1616
# Flexible test selection mechanism for NVIDIA GPU environments
1717
#
18-
# This platform configuration supports multiple device types (a100, a800, h100)
18+
# This platform configuration supports multiple device types (a100, a800, h100, h20)
1919
# Users can modify the following:
2020
# - functional: Add/remove test case names in the support lists
2121
# Example: aquila: ["tp2_pp2", "tp4_pp2"] -> add or remove items
@@ -45,6 +45,10 @@ device_types:
4545
compute_capability: "9.0"
4646
memory_gb: 80
4747
tags: [hopper, fp8, bf16]
48+
h20:
49+
compute_capability: "9.0"
50+
memory_gb: 140
51+
tags: [hopper, fp8, bf16, china-variant]
4852

4953
# Default numerical tolerance for result comparison (used when device doesn't override)
5054
tolerance:
@@ -97,3 +101,10 @@ a100:
97101
benchmark:
98102
enabled: true
99103
smoke: ["throughput", "latency", "serve"]
104+
105+
h20:
106+
name: "h20"
107+
tests:
108+
e2e:
109+
serving:
110+
qwen3_6: ["27b_tp2_262k", "35b_a3b_tp2_262k"]

tests/utils/model_config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ class ServeConfig:
108108
``"embedding"``).
109109
completion_prompt: Prompt string for ``/v1/completions`` endpoint.
110110
chat_messages: Messages list for ``/v1/chat/completions`` endpoint.
111+
chat_cases: Named chat cases to run against one server instance.
111112
max_tokens: Max tokens for serving requests.
112113
served_model_name: Alias passed via ``--served-model-name``.
113114
Empty string means use the model path directly.
@@ -127,6 +128,7 @@ class ServeConfig:
127128
endpoints: list[str] = field(default_factory=list)
128129
completion_prompt: str = "Hello"
129130
chat_messages: list[dict[str, Any]] = field(default_factory=list)
131+
chat_cases: list[dict[str, Any]] = field(default_factory=list)
130132
max_tokens: int = 50
131133
served_model_name: str = ""
132134
startup_retries: int = 60
@@ -147,6 +149,7 @@ def from_dict(cls, raw: dict[str, Any]) -> ServeConfig:
147149
endpoints=raw.get("endpoints", []),
148150
completion_prompt=raw.get("completion_prompt", "Hello"),
149151
chat_messages=raw.get("chat_messages", []),
152+
chat_cases=raw.get("chat_cases", []),
150153
max_tokens=raw.get("max_tokens", 50),
151154
served_model_name=raw.get("served_model_name", ""),
152155
startup_retries=int(raw.get("startup_retries", 60)),

0 commit comments

Comments
 (0)