[Feature][Frontend] Add DeepSeek-V4 FIM completion rendering (#44229)

Signed-off-by: QwertyJack <7554089+QwertyJack@users.noreply.github.com> Co-authored-by: QwertyJack <7554089+QwertyJack@users.noreply.github.com> Co-authored-by: Chauncey <chaunceyjiang@gmail.com>

jack committed Sep 23, 2026 at 21:02 UTC d95a896ea612a072ba986a708d7efb4de1547019
5 files changed +243 -7
tests/entrypoints/openai/completion/test_completion_error.py
+178
@@ -14,8 +14,10 @@ from vllm.entrypoints.openai.completion.serving import OpenAIServingCompletion
14 from vllm.entrypoints.openai.models.protocol import BaseModelPath
15 from vllm.entrypoints.openai.models.serving import OpenAIServingModels
16 from vllm.entrypoints.scale_out.render.serving import ServingRender
17 +from vllm.entrypoints.serve.engine.protocol import ErrorResponse
18 from vllm.exceptions import GenerationError, VLLMValidationError
19 from vllm.outputs import CompletionOutput, RequestOutput
20 +from vllm.renderers.deepseek_v4 import DeepseekV4Renderer
21 from vllm.renderers.hf import HfRenderer
22 from vllm.renderers.online_renderer import OnlineRenderer
23 from vllm.tokenizers.registry import cached_tokenizer_from_config
@@ -24,6 +26,9 @@ from vllm.v1.metrics.stats import RequestSpecDecodeMetrics, RequestStateStats
26
27 MODEL_NAME = "openai-community/gpt2"
28 MODEL_NAME_SHORT = "gpt2"
29 +DEEPSEEK_V4_FIM_BEGIN = "<|fim▁begin|>"
30 +DEEPSEEK_V4_FIM_HOLE = "<|fim▁hole|>"
31 +DEEPSEEK_V4_FIM_END = "<|fim▁end|>"
32 _PER_REQUEST_STATS = RequestStateStats(
33 queued_ts=1.0,
34 scheduled_ts=1.5,
@@ -302,6 +307,179 @@ def test_completion_metrics_carries_both_timing_and_spec_decode():
307 assert response.metrics.speculative_decoding.num_spec_steps == 2
308
309
310 +def _build_online_renderer_for_completion(
311 + model_config: MockModelConfig,
312 + renderer: Any | None = None,
313 +) -> OnlineRenderer:
314 + if renderer is None:
315 + renderer = MagicMock()
316 + renderer.render_completion_suffix.return_value = None
317 +
318 + return OnlineRenderer(
319 + model_config=model_config,
320 + renderer=renderer,
321 + request_logger=None,
322 + chat_template=None,
323 + chat_template_content_format="auto",
324 + )
325 +
326 +
327 +def _deepseek_v4_suffix_renderer(
328 + model_config: MockModelConfig,
329 +) -> DeepseekV4Renderer:
330 + return DeepseekV4Renderer(
331 + MockVllmConfig(model_config, parallel_config=MockParallelConfig()),
332 + None,
333 + )
334 +
335 +
336 +@pytest.mark.asyncio
337 +@pytest.mark.skip_global_cleanup
338 +async def test_completion_suffix_uses_renderer_capability():
339 + model_config = MockModelConfig()
340 + online_renderer = _build_online_renderer_for_completion(
341 + model_config,
342 + renderer=_deepseek_v4_suffix_renderer(model_config),
343 + )
344 + online_renderer.preprocess_completion = AsyncMock(return_value=[{"ok": True}])
345 +
346 + request = CompletionRequest(
347 + model=MODEL_NAME,
348 + prompt="def fib(n):\n return ",
349 + suffix="\n\nprint(fib(10))",
350 + max_tokens=64,
351 + )
352 +
353 + result = await online_renderer.render_completion(request)
354 +
355 + assert result == [{"ok": True}]
356 + online_renderer.preprocess_completion.assert_awaited_once()
357 + assert online_renderer.preprocess_completion.call_args.kwargs["prompt_input"] == (
358 + f"{DEEPSEEK_V4_FIM_BEGIN}def fib(n):\n return "
359 + f"{DEEPSEEK_V4_FIM_HOLE}\n\nprint(fib(10))"
360 + f"{DEEPSEEK_V4_FIM_END}"
361 + )
362 +
363 +
364 +@pytest.mark.asyncio
365 +@pytest.mark.skip_global_cleanup
366 +async def test_completion_suffix_supports_text_prompt_list():
367 + model_config = MockModelConfig()
368 + online_renderer = _build_online_renderer_for_completion(
369 + model_config,
370 + renderer=_deepseek_v4_suffix_renderer(model_config),
371 + )
372 + online_renderer.preprocess_completion = AsyncMock(return_value=[{"ok": True}])
373 +
374 + request = CompletionRequest(
375 + model=MODEL_NAME,
376 + prompt=["prefix A", "prefix B"],
377 + suffix=" suffix",
378 + )
379 +
380 + result = await online_renderer.render_completion(request)
381 +
382 + assert result == [{"ok": True}]
383 + assert online_renderer.preprocess_completion.call_args.kwargs["prompt_input"] == [
384 + f"{DEEPSEEK_V4_FIM_BEGIN}prefix A"
385 + f"{DEEPSEEK_V4_FIM_HOLE} suffix{DEEPSEEK_V4_FIM_END}",
386 + f"{DEEPSEEK_V4_FIM_BEGIN}prefix B"
387 + f"{DEEPSEEK_V4_FIM_HOLE} suffix{DEEPSEEK_V4_FIM_END}",
388 + ]
389 +
390 +
391 +@pytest.mark.asyncio
392 +@pytest.mark.skip_global_cleanup
393 +async def test_completion_suffix_rejects_renderer_without_fim_support():
394 + model_config = MockModelConfig()
395 + online_renderer = _build_online_renderer_for_completion(model_config)
396 + online_renderer.preprocess_completion = AsyncMock()
397 +
398 + request = CompletionRequest(
399 + model=MODEL_NAME,
400 + prompt="prefix",
401 + suffix="suffix",
402 + )
403 +
404 + result = await online_renderer.render_completion(request)
405 +
406 + assert isinstance(result, ErrorResponse)
407 + assert "FIM completion rendering" in result.error.message
408 + online_renderer.preprocess_completion.assert_not_awaited()
409 +
410 +
411 +@pytest.mark.asyncio
412 +@pytest.mark.skip_global_cleanup
413 +async def test_completion_suffix_rejects_echo():
414 + model_config = MockModelConfig()
415 + online_renderer = _build_online_renderer_for_completion(
416 + model_config,
417 + renderer=_deepseek_v4_suffix_renderer(model_config),
418 + )
419 + online_renderer.preprocess_completion = AsyncMock()
420 +
421 + request = CompletionRequest(
422 + model=MODEL_NAME,
423 + prompt="prefix",
424 + suffix="suffix",
425 + echo=True,
426 + )
427 +
428 + result = await online_renderer.render_completion(request)
429 +
430 + assert isinstance(result, ErrorResponse)
431 + assert "Echo is unsupported with suffix" in result.error.message
432 + online_renderer.preprocess_completion.assert_not_awaited()
433 +
434 +
435 +@pytest.mark.asyncio
436 +@pytest.mark.skip_global_cleanup
437 +async def test_completion_suffix_rejects_prompt_embeds():
438 + model_config = MockModelConfig()
439 + online_renderer = _build_online_renderer_for_completion(
440 + model_config,
441 + renderer=_deepseek_v4_suffix_renderer(model_config),
442 + )
443 + online_renderer.preprocess_completion = AsyncMock()
444 +
445 + request = CompletionRequest(
446 + model=MODEL_NAME,
447 + prompt="prefix",
448 + suffix="suffix",
449 + prompt_embeds=b"embeds",
450 + )
451 +
452 + result = await online_renderer.render_completion(request)
453 +
454 + assert isinstance(result, ErrorResponse)
455 + assert "suffix is not supported with prompt_embeds" in result.error.message
456 + online_renderer.preprocess_completion.assert_not_awaited()
457 +
458 +
459 +@pytest.mark.asyncio
460 +@pytest.mark.skip_global_cleanup
461 +@pytest.mark.parametrize("prompt", ([1, 2, 3], [[1, 2, 3]]))
462 +async def test_completion_suffix_rejects_token_prompt(prompt):
463 + model_config = MockModelConfig()
464 + online_renderer = _build_online_renderer_for_completion(
465 + model_config,
466 + renderer=_deepseek_v4_suffix_renderer(model_config),
467 + )
468 + online_renderer.preprocess_completion = AsyncMock()
469 +
470 + request = CompletionRequest(
471 + model=MODEL_NAME,
472 + prompt=prompt,
473 + suffix="suffix",
474 + )
475 +
476 + result = await online_renderer.render_completion(request)
477 +
478 + assert isinstance(result, ErrorResponse)
479 + assert "requires text prompt input" in result.error.message
480 + online_renderer.preprocess_completion.assert_not_awaited()
481 +
482 +
483 @pytest.mark.asyncio
484 async def test_completion_error_non_stream():
485 """Test finish_reason='error' returns 500 InternalServerError (non-streaming)."""
vllm/entrypoints/openai/completion/serving.py
+1 -3
@@ -116,9 +116,7 @@ class OpenAIServingCompletion(GenerateBaseServing):
116 See https://platform.openai.com/docs/api-reference/completions/create
117 for the API specification. This API mimics the OpenAI Completion API.
118
119 - NOTE: Currently we do not support the following feature:
120 - - suffix (the language models we currently support do not support
121 - suffix)
119 + NOTE: suffix is only supported by models that implement FIM rendering.
120 """
121 return await self._with_kv_transfer_rejection_cleanup(
122 self._create_completion(request, raw_request), request, raw_request
vllm/renderers/base.py
+4
@@ -193,6 +193,10 @@ class BaseRenderer(ABC, Generic[_T]):
193
194 return tokenizer
195
196 + def render_completion_suffix(self, prompt: str, suffix: str) -> str | None:
197 + """Render OpenAI completion suffix input when the renderer supports FIM."""
198 + return None
199 +
200 def _decode(self, *args, **kwargs):
201 return self.get_tokenizer().decode(*args, **kwargs)
202
vllm/renderers/deepseek_v4.py
+7
@@ -16,6 +16,10 @@ from .inputs import DictPrompt
16 from .inputs.preprocess import parse_dec_only_prompt
17 from .params import ChatParams
18
19 +_FIM_BEGIN = "<|fim▁begin|>"
20 +_FIM_HOLE = "<|fim▁hole|>"
21 +_FIM_END = "<|fim▁end|>"
22 +
23
24 class DeepseekV4Renderer(BaseRenderer[DeepseekV4Tokenizer]):
25 def __init__(
@@ -32,6 +36,9 @@ class DeepseekV4Renderer(BaseRenderer[DeepseekV4Tokenizer]):
36 def _apply_chat_template(self, *args, **kwargs):
37 return self.get_tokenizer().apply_chat_template(*args, **kwargs)
38
39 + def render_completion_suffix(self, prompt: str, suffix: str) -> str | None:
40 + return f"{_FIM_BEGIN}{prompt}{_FIM_HOLE}{suffix}{_FIM_END}"
41 +
42 def render_messages(
43 self,
44 messages: list[ChatCompletionMessageParam],
vllm/renderers/online_renderer.py
+53 -4
@@ -4,7 +4,7 @@ import time
4 from collections.abc import Sequence
5 from dataclasses import dataclass
6 from http import HTTPStatus
7 -from typing import TYPE_CHECKING, Any, TypeAlias
7 +from typing import TYPE_CHECKING, Any, TypeAlias, cast
8
9 from openai.types.responses import ResponseFunctionToolCall, ResponseOutputItem
10 from openai.types.responses.tool import Mcp, Tool
@@ -548,9 +548,58 @@ class OnlineRenderer:
548 Called directly by render_completion_request and delegated to by
549 OpenAIServingCompletion.render_completion_request after its engine-aware checks.
550 """
551 - # Return error for unsupported features.
551 + prompt_input = request.prompt
552 if request.suffix is not None:
553 - return self.create_error_response("suffix is not currently supported")
553 + if request.echo:
554 + return self.create_error_response(
555 + "Echo is unsupported with suffix.",
556 + param="suffix",
557 + )
558 +
559 + if request.prompt_embeds is not None:
560 + return self.create_error_response(
561 + "suffix is not supported with prompt_embeds",
562 + param="suffix",
563 + )
564 +
565 + if request.truncate_prompt_tokens is not None:
566 + return self.create_error_response(
567 + "suffix is not supported with truncate_prompt_tokens",
568 + param="suffix",
569 + )
570 +
571 + if isinstance(request.prompt, str):
572 + rendered_prompt = self.renderer.render_completion_suffix(
573 + request.prompt, request.suffix
574 + )
575 + if rendered_prompt is None:
576 + return self.create_error_response(
577 + "suffix is only supported for models with FIM completion "
578 + "rendering",
579 + param="suffix",
580 + )
581 + prompt_input = rendered_prompt
582 + elif isinstance(request.prompt, list) and all(
583 + isinstance(prompt, str) for prompt in request.prompt
584 + ):
585 + rendered_prompts = []
586 + for prompt in cast(list[str], request.prompt):
587 + rendered_prompt = self.renderer.render_completion_suffix(
588 + prompt, request.suffix
589 + )
590 + if rendered_prompt is None:
591 + return self.create_error_response(
592 + "suffix is only supported for models with FIM completion "
593 + "rendering",
594 + param="suffix",
595 + )
596 + rendered_prompts.append(rendered_prompt)
597 + prompt_input = rendered_prompts
598 + else:
599 + return self.create_error_response(
600 + "suffix requires text prompt input for FIM completion rendering",
601 + param="suffix",
602 + )
603
604 if request.echo and request.prompt_embeds is not None:
605 return self.create_error_response("Echo is unsupported with prompt embeds.")
@@ -562,7 +611,7 @@ class OnlineRenderer:
611
612 engine_inputs = await self.preprocess_completion(
613 request,
565 - prompt_input=request.prompt,
614 + prompt_input=prompt_input,
615 prompt_embeds=request.prompt_embeds,
616 skip_mm_cache=skip_mm_cache,
617 )