Skip to content

Commit 990a3a7

Browse files
authored
Fix chat endpoint support for HF deployment with Ray (#575)
Signed-off-by: Abhishree <abhishreetm@gmail.com>
1 parent ec63263 commit 990a3a7

4 files changed

Lines changed: 306 additions & 12 deletions

File tree

‎nemo_deploy/llm/hf_deployable.py‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,45 @@ def _load(
155155
if self.tokenizer.pad_token is None:
156156
self.tokenizer.pad_token = self.tokenizer.eos_token
157157

158+
def apply_chat_template(self, messages, add_generation_prompt=True):
159+
"""Apply the chat template to messages using the tokenizer.
160+
161+
This method uses the HuggingFace tokenizer's built-in apply_chat_template method
162+
to format messages according to the model's expected chat format.
163+
164+
Args:
165+
messages: List of message dictionaries with 'role' and 'content' keys,
166+
or a JSON string representation of messages.
167+
add_generation_prompt (bool): Whether to add the generation prompt. Defaults to True.
168+
169+
Returns:
170+
str: The formatted prompt string.
171+
172+
Raises:
173+
ValueError: If the tokenizer does not have a chat template.
174+
"""
175+
import json
176+
177+
# Handle JSON string input
178+
if isinstance(messages, str):
179+
messages = json.loads(messages)
180+
181+
# Check if tokenizer has chat_template
182+
if not hasattr(self.tokenizer, "chat_template") or self.tokenizer.chat_template is None:
183+
raise ValueError(
184+
"The tokenizer does not have a chat template defined. "
185+
"If you would like to evaluate a chat model, ensure your model's tokenizer has a chat template."
186+
)
187+
188+
# Use tokenizer's apply_chat_template method
189+
formatted_prompt = self.tokenizer.apply_chat_template(
190+
messages,
191+
tokenize=False,
192+
add_generation_prompt=add_generation_prompt,
193+
)
194+
195+
return formatted_prompt
196+
158197
def generate(
159198
self,
160199
**kwargs: Any,
@@ -529,6 +568,7 @@ def ray_infer_fn(self, inputs: Dict[Any, Any]):
529568
- compute_logprob: Whether to compute log probabilities (optional)
530569
- n_top_logprobs: Number of top log probabilities to return (optional)
531570
- echo: Whether to echo the prompt in output (optional)
571+
- apply_chat_template: Whether to apply chat template to prompts (optional)
532572
533573
Returns:
534574
Dict[str, Any]: Dictionary containing:
@@ -549,6 +589,7 @@ def ray_infer_fn(self, inputs: Dict[Any, Any]):
549589
compute_logprob = inputs.pop("compute_logprob", False)
550590
n_top_logprobs = inputs.pop("n_top_logprobs", 0)
551591
echo = inputs.pop("echo", False)
592+
apply_chat_template = inputs.pop("apply_chat_template", False)
552593

553594
output_infer = self._infer_fn_ray(
554595
prompts=prompts,
@@ -561,6 +602,7 @@ def ray_infer_fn(self, inputs: Dict[Any, Any]):
561602
compute_logprob=compute_logprob,
562603
n_top_logprobs=n_top_logprobs,
563604
echo=echo,
605+
apply_chat_template=apply_chat_template,
564606
)
565607
# Code to get logprobs (required in OAI API format for eval) from the scores in output_infer.
566608
if (compute_logprob or n_top_logprobs > 0) and "scores" in output_infer and output_infer["scores"]:
@@ -600,6 +642,7 @@ def _infer_fn_ray(
600642
compute_logprob=False,
601643
n_top_logprobs=0,
602644
echo=False,
645+
apply_chat_template=False,
603646
cast_output_func=None,
604647
):
605648
"""Common internal function for inference operations.
@@ -615,11 +658,16 @@ def _infer_fn_ray(
615658
compute_logprob: Whether to compute log probabilities
616659
n_top_logprobs: Number of top log probabilities to return
617660
echo: Whether to echo the prompt in output
661+
apply_chat_template: Whether to apply chat template to prompts
618662
cast_output_func: Optional function to cast output values
619663
620664
Returns:
621665
Dict containing inference results with raw outputs
622666
"""
667+
# Apply chat template if requested (for Ray inference only)
668+
if apply_chat_template:
669+
prompts = [self.apply_chat_template(prompt) for prompt in prompts]
670+
623671
# Enable return_dict if we need scores for logprobs or if output_logits/scores are requested
624672
return_dict_in_generate = output_logits or output_scores or compute_logprob or n_top_logprobs > 0
625673
# Enable output_scores if we need to compute logprobs. scores and logits from generate are both identical in

‎nemo_deploy/llm/hf_deployable_ray.py‎

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -294,27 +294,29 @@ async def chat_completions(self, request: Dict[Any, Any]):
294294
# Extract parameters from the request dictionary
295295
messages = request.get("messages", [])
296296

297+
# Get sampling parameters
298+
temperature = request.get("temperature", 0.0)
297299
# vLLM requires top_p to be in (0, 1], so handle invalid values
298300
top_p = request.get("top_p", None)
299301
if top_p is not None and top_p <= 0.0:
300302
LOGGER.warning(
301303
f"top_p must be in (0, 1] for vLLM, got {top_p}. Setting to 0.1 for greedy-like sampling."
302304
)
303305
request["top_p"] = 0.1
304-
305-
# Convert messages to a single prompt
306-
prompt = "\n".join([f"{msg.get('role', 'user')}: {msg.get('content', '')}" for msg in messages])
307-
prompt += "\nassistant:"
306+
if temperature == 0.0:
307+
LOGGER.warning("Both temperature and top_p are 0. Setting top_k to 1 to ensure greedy sampling.")
308+
request["top_k"] = 1
308309

309310
# Prepare inference parameters using the formatted prompt
310311
inference_inputs = {
311-
"prompts": [prompt], # Use formatted prompt string instead of raw messages
312+
"prompts": [messages], # Pass messages list, will be formatted by apply_chat_template
312313
"max_tokens": request.get("max_tokens", 256),
313-
"temperature": request.get("temperature", 1.0),
314+
"temperature": temperature,
314315
"top_k": request.get("top_k", 0),
315316
"top_p": request.get("top_p", 1.0), # vLLM requires top_p in (0, 1], use 1.0 as default
316317
"output_logits": request.get("output_logits", False),
317318
"output_scores": request.get("output_scores", False),
319+
"apply_chat_template": request.get("apply_chat_template", True),
318320
}
319321

320322
# Run model inference in the thread pool
@@ -353,12 +355,7 @@ async def chat_completions(self, request: Dict[Any, Any]):
353355
"scores": scores,
354356
}
355357
if scores is not None
356-
else None,
357-
{
358-
"logits": logits,
359-
}
360-
if logits is not None
361-
else None,
358+
else None
362359
),
363360
"finish_reason": (
364361
"length"

‎tests/unit_tests/deploy/test_hf_deployable.py‎

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,131 @@ def test_ray_infer_fn_max_tokens_edge_cases(self, mock_model, mock_tokenizer, mo
378378
assert "sentences" in output
379379

380380

381+
class TestHFDeployableApplyChatTemplate:
382+
"""Tests for apply_chat_template functionality added to HuggingFaceLLMDeploy."""
383+
384+
@pytest.fixture
385+
def mock_tokenizer_with_chat_template(self):
386+
"""Create a mock tokenizer with chat template support."""
387+
tokenizer = MagicMock(spec=AutoTokenizer)
388+
tokenizer.pad_token = "[PAD]"
389+
tokenizer.eos_token = "[EOS]"
390+
tokenizer.chat_template = "{% for message in messages %}{{ message.role }}: {{ message.content }}\n{% endfor %}"
391+
tokenizer.apply_chat_template = MagicMock(return_value="<|begin_of_text|>user: Hello\nassistant:")
392+
return tokenizer
393+
394+
@pytest.fixture
395+
def mock_tokenizer_without_chat_template(self):
396+
"""Create a mock tokenizer without chat template support."""
397+
tokenizer = MagicMock(spec=AutoTokenizer)
398+
tokenizer.pad_token = "[PAD]"
399+
tokenizer.eos_token = "[EOS]"
400+
tokenizer.chat_template = None
401+
return tokenizer
402+
403+
@pytest.fixture
404+
def hf_deployable_with_chat_template(self, mock_model, mock_tokenizer_with_chat_template):
405+
"""Create HuggingFaceLLMDeploy instance with chat template support."""
406+
deployer = HuggingFaceLLMDeploy(
407+
model=mock_model, tokenizer=mock_tokenizer_with_chat_template, task="text-generation"
408+
)
409+
return deployer
410+
411+
@pytest.fixture
412+
def hf_deployable_without_chat_template(self, mock_model, mock_tokenizer_without_chat_template):
413+
"""Create HuggingFaceLLMDeploy instance without chat template support."""
414+
deployer = HuggingFaceLLMDeploy(
415+
model=mock_model, tokenizer=mock_tokenizer_without_chat_template, task="text-generation"
416+
)
417+
return deployer
418+
419+
def test_apply_chat_template_with_messages_list(self, hf_deployable_with_chat_template):
420+
"""Test apply_chat_template with a list of message dictionaries."""
421+
messages = [{"role": "user", "content": "Hello, how are you?"}]
422+
423+
result = hf_deployable_with_chat_template.apply_chat_template(messages)
424+
425+
assert result == "<|begin_of_text|>user: Hello\nassistant:"
426+
hf_deployable_with_chat_template.tokenizer.apply_chat_template.assert_called_once_with(
427+
messages,
428+
tokenize=False,
429+
add_generation_prompt=True,
430+
)
431+
432+
def test_apply_chat_template_with_json_string(self, hf_deployable_with_chat_template):
433+
"""Test apply_chat_template with JSON string input."""
434+
import json
435+
436+
messages = [{"role": "user", "content": "Hello"}]
437+
messages_json = json.dumps(messages)
438+
439+
result = hf_deployable_with_chat_template.apply_chat_template(messages_json)
440+
441+
assert result == "<|begin_of_text|>user: Hello\nassistant:"
442+
# Verify it was called with the parsed list
443+
hf_deployable_with_chat_template.tokenizer.apply_chat_template.assert_called_once()
444+
445+
def test_apply_chat_template_without_generation_prompt(self, hf_deployable_with_chat_template):
446+
"""Test apply_chat_template with add_generation_prompt=False."""
447+
messages = [{"role": "user", "content": "Hello"}]
448+
449+
hf_deployable_with_chat_template.apply_chat_template(messages, add_generation_prompt=False)
450+
451+
hf_deployable_with_chat_template.tokenizer.apply_chat_template.assert_called_once_with(
452+
messages,
453+
tokenize=False,
454+
add_generation_prompt=False,
455+
)
456+
457+
def test_apply_chat_template_raises_error_when_no_template(self, hf_deployable_without_chat_template):
458+
"""Test apply_chat_template raises ValueError when tokenizer has no chat template."""
459+
messages = [{"role": "user", "content": "Hello"}]
460+
461+
with pytest.raises(ValueError, match="The tokenizer does not have a chat template defined"):
462+
hf_deployable_without_chat_template.apply_chat_template(messages)
463+
464+
def test_ray_infer_fn_with_apply_chat_template(
465+
self, mock_model, mock_tokenizer_with_chat_template, mock_torch_cuda
466+
):
467+
"""Test ray_infer_fn correctly applies chat template when requested."""
468+
deployer = HuggingFaceLLMDeploy(
469+
model=mock_model, tokenizer=mock_tokenizer_with_chat_template, task="text-generation"
470+
)
471+
472+
messages = [{"role": "user", "content": "Hello"}]
473+
inputs = {
474+
"prompts": [messages],
475+
"max_tokens": 100,
476+
"apply_chat_template": True,
477+
}
478+
479+
result = deployer.ray_infer_fn(inputs)
480+
481+
assert "sentences" in result
482+
# Verify apply_chat_template was called
483+
mock_tokenizer_with_chat_template.apply_chat_template.assert_called()
484+
485+
def test_ray_infer_fn_without_apply_chat_template(
486+
self, mock_model, mock_tokenizer_with_chat_template, mock_torch_cuda
487+
):
488+
"""Test ray_infer_fn does not apply chat template when not requested."""
489+
deployer = HuggingFaceLLMDeploy(
490+
model=mock_model, tokenizer=mock_tokenizer_with_chat_template, task="text-generation"
491+
)
492+
493+
inputs = {
494+
"prompts": ["plain text prompt"],
495+
"max_tokens": 100,
496+
"apply_chat_template": False,
497+
}
498+
499+
result = deployer.ray_infer_fn(inputs)
500+
501+
assert "sentences" in result
502+
# Verify apply_chat_template was NOT called
503+
mock_tokenizer_with_chat_template.apply_chat_template.assert_not_called()
504+
505+
381506
class TestHFDeployableEchoAndLogprobs:
382507
"""Tests for echo parameter and logprobs functionality added to HuggingFaceLLMDeploy."""
383508

0 commit comments

Comments
 (0)