11_Comprehensive Guide to Contributing to Open Source (1 entry)
Introduction
This post details my contributions to an open-source project.
The pull request I submitted to the vLLM project, PR #46793 — [Frontend] Support bad_words in the /v1/completions endpoint, has been merged.
To summarize in one sentence:
This fix addressed the issue where "a feature that worked in the Chat API didn’t work in the older completions API".
I’m documenting this as a good example of why achieving “feature parity” in open source is so important.
1. What is bad_words?
bad_words is an option that instructs the model to "never generate these words".
For example, when an internal chatbot must not output specific prohibited words or competitor names, rather than relying on prompts to manage this, we set the probability of those tokens appearing to 0 during the sampling stage. This is much more reliable and harder to circumvent.
Within vLLM, the component that actually receives and processes this value is SamplingParams. The key point is that SamplingParams already fully supported bad_words. The engine was fully ready; it was just a matter of one entry point (the API) being blocked.
2. Problem — The Discrepancy Between the Two Endpoints
vLLM provides two sets of OpenAI-compatible APIs.
| Endpoint | Purpose | bad_words Support |
|---|---|---|
/v1/chat/completions | Latest chat format (message-based) | ✅ Supported |
/v1/completions | Legacy format (prompt-based) | ❌ Not supported |
Even though they use the same engine and the same SamplingParams, it’s as if one door has a handle while the other doesn’t. Although it’s called “legacy,” /v1/completions is still an active endpoint used by countless services. Users who rely on this API get stuck wondering, “Why can’t I set up blacklisted words?” This kind of functional inconsistency between endpoints is the most confusing type of bug from a user’s perspective—because it should work, but it doesn’t.
The cause was simple. The request model for the chat side (ChatCompletionRequest) defines the bad_words field, which is passed to the engine via to_sampling_params(), but the request model for the completions side (CompletionRequest) was missing those two parts entirely.
3. What Was Fixed and Why
There was only one file to fix: vllm/entrypoints/openai/completion/protocol.py. I determined that the task simply involved mirroring the chat-side implementation exactly.
(1) Add a field to the request model — This creates an entry point so that users can send bad_words via the API.
# Inside the CompletionRequest class
allowed_token_ids: list[int] | None = None
prompt_logprobs: int | None = None
+ bad_words: list[str] = Field(default_factory=list)
I set the default value for default_factory=list to an empty list. If no value is sent, it behaves exactly as before—it doesn’t block any words. I made sure this matches the default value of the chat endpoint exactly so that both APIs behave identically.
(2) Pass to the engine — Pass the received value to SamplingParams, which actually handles the processing.
# Inside `def to_sampling_params(...)`
logit_bias=self.logit_bias,
allowed_token_ids=self.allowed_token_ids,
+ bad_words=self.bad_words,
extra_args=extra_args or None,
If you only define a field but don’t pass it to the engine, the value is simply discarded. This single line is the “plumbing” that connects the “value received at the entry point” to the “actual action.” To summarize:
| Step | What it does | What was done in this PR |
|---|---|---|
| ① Receive API request | User sends bad_words as JSON | Add field definition |
| ② Pass SamplingParams | Pass the received value as engine parameters | bad_words=self.bad_words plumbing |
| ③ Suppress actual token | Set the probability of that token to 0 | Already handled by the existing engine |
In other words, no new logic was created. We simply opened an entry point to an engine feature that was already complete. This is the safest type of open-source contribution and the one most likely to pass code reviews—a change that “replicates existing, proven patterns exactly to ensure consistency.”
4. Testing — Two unit tests that run on CPU alone
To ensure rapid verification in CI even without a GPU, I added unit tests that specifically check only the parameter conversion logic instead of performing heavy inference.
def test_completion_request_bad_words_to_sampling_params():
"""Is `bad_words` passed to `SamplingParams` (same behavior as in the chat)?"""
request = CompletionRequest(
model="test-model", prompt="Hello",
bad_words=["foo", "bar"], max_tokens=10,
)
sampling_params = request.to_sampling_params(
max_tokens=10, default_sampling_params={},
)
assert sampling_params.bad_words == ["foo", "bar"] # Were the values passed as-is?
def test_completion_request_bad_words_default_empty():
"""If no value is provided, an empty list (same as the default for the chat endpoint)"""
request = CompletionRequest(model="test-model", prompt="Hello", max_tokens=10)
assert request.bad_words == []
sampling_params = request.to_sampling_params(
max_tokens=10, default_sampling_params={},
)
assert sampling_params.bad_words == []
What each of the two tests verifies:
| Test | Verification | Why It’s Needed |
|---|---|---|
..._to_sampling_params | Sent values reach the engine | To verify that the wiring is actually connected |
..._default_empty | Empty list when unspecified | To ensure existing user behavior isn’t broken (backward compatibility) |
This effectively provides code-based answers in advance to the two questions reviewers ask first: “Does the new feature work?” and “Does it break existing functionality?”
5. Results
| Item | Details |
|---|---|
| PR Number | #46793 |
| Title | Frontend Support for bad_words in the /v1/completions endpoint |
| Status | Merged |
| Scope of Changes | +39 / -0, 2 files |
| Key Changes | 1 line of field definition + 1 line of engine wiring + 2 unit tests |
Before submitting, I searched for existing PRs related to bad_words to confirm that this work was not a duplicate (the existing PRs fixed tokenizer conversion/caching bugs, but none added fields to the completions endpoint), and documented this in the PR body. This preliminary check significantly reduces the number of review back-and-forths.
Conclusion
Although this is a two-line
code change, the lesson it teaches goes beyond its scale.
- Engine functionality and API exposure are separate. Even if a feature is supported internally, if there’s no way to access it, it might as well not exist to the user.
- Consistency is user experience. If something works in one endpoint but not in another, users will perceive that as a bug.
- Small contributions lower the barrier to entry. If you replicate existing patterns, maintain backward compatibility, and validate your changes with tests—the path to a merge is short.
Contributions aren’t limited to major features. Even something as simple as opening one more door is a change that someone using that API desperately needed.
This article presents research results conducted with support from the Ministry of Science and ICT and the National IT Industry Promotion Agency’s “2026 Open Source AI and Software Development and Utilization Support Project.”