mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
## Description
When Headroom forwards a `/v1/chat/completions` request to an
OpenAI-compatible backend (vLLM) via the LiteLLM backend, the
non-standard-but-OpenAI-compatible top-level field
`chat_template_kwargs` (e.g. `{"chat_template_kwargs":
{"enable_thinking": false}}`, used by vLLM to toggle Qwen3-family
"thinking" mode per request) never reaches the upstream model. A caller
that needs thinking *off* for a specific request has no way to disable
it through Headroom: the reasoning model spends its whole output-token
budget on hidden `<think>...</think>` content and returns
empty/truncated visible content.
Root cause: `LiteLLMBackend.send_openai_message`
(`headroom/backends/litellm.py:1101-1210`) and `stream_openai_message`
(`headroom/backends/litellm.py:1285+`) build the outgoing LiteLLM
`kwargs` from an explicit allowlist of recognized OpenAI params
(`headroom/backends/litellm.py:1129-1141`: `max_tokens`, `temperature`,
`top_p`, `stop`, `tools`, `tool_choice`, `response_format`, `seed`,
`n`). Only `model` and `messages` are copied unconditionally; anything
not in the list — including `chat_template_kwargs` — is dropped before
`acompletion(**kwargs)`. This is exactly the "litellm-backed forwarding
only passes fields it recognizes as standard OpenAI params" the reporter
suspected.
LiteLLM already forwards arbitrary vendor fields to an OpenAI-compatible
backend verbatim through its documented `extra_body` parameter — the
same mechanism vLLM users use directly. This change collects the
top-level body fields Headroom does not consume as standard params and
forwards them under `extra_body`, so `chat_template_kwargs` (and any
other vendor top-level field) reaches vLLM unchanged, on both the
buffered and streaming paths.
Closes #2128.
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- In `LiteLLMBackend.send_openai_message` and `stream_openai_message`,
after populating the standard-param allowlist, collect top-level `body`
keys not consumed by Headroom/LiteLLM (everything outside the standard
allowlist plus `model`/`messages`/`stream`/`stream_options` and internal
markers) and forward them to the backend via LiteLLM's `extra_body`.
- `chat_template_kwargs` and other vendor-specific top-level fields now
reach the OpenAI-compatible upstream verbatim.
- Left the standard-param allowlist, region/profile config, API-key
forwarding, and the cache-stats usage block untouched; standard params
stay first-class LiteLLM kwargs (not moved into `extra_body`).
- Scoped to the OpenAI-format methods; the Anthropic-format
`send_message`/`stream_message` and the metadata-only
`OpenAICompatibleProvider` are unchanged.
## Testing
- [x] Unit tests pass (`uv run pytest
tests/test_litellm_openai_passthrough.py`)
- [x] Linting passes (`uv run ruff check .`)
- [ ] Type checking passes (`uv run mypy headroom`)
- [x] New tests added for new functionality when applicable
- [x] Manual testing performed
### Test Output
```text
$ uv run pytest tests/test_litellm_openai_passthrough.py -q
.... [100%]
4 passed in 1.88s
```
## Real Behavior Proof
- Environment: Windows 11, Python 3.12, `uv run`; `acompletion` mocked
(no live vLLM).
- Exact command / steps: `uv run pytest
tests/test_litellm_openai_passthrough.py -q`, which drives
`send_openai_message` and `stream_openai_message` with a body containing
`chat_template_kwargs: {"enable_thinking": false}` and inspects the
captured `acompletion` call kwargs.
- Observed result: on both the buffered and streaming paths
`acompletion` is now called with `extra_body={"chat_template_kwargs":
{"enable_thinking": false}}`; a standard-only body produces no
`extra_body`, and standard params (`max_tokens`, `temperature`, …)
remain first-class kwargs. Before the change the same body reaches
`acompletion` with `chat_template_kwargs` absent.
- Not tested: live vLLM run confirming Qwen3 thinking mode toggles off
end-to-end.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md if applicable
## Additional Notes
- This implements reporter option (a): pass unrecognized top-level body
fields through verbatim (via `extra_body`), which needs no new config
surface. Reporter option (b), an explicit allowlist/config on the
provider, is a deliberate non-goal here and can follow if maintainers
prefer it. The Anthropic-format `send_message`/`stream_message`
translation path and the direct-httpx passthrough path (which already
forwards the full body) are out of scope.
- Issue diagnosed by George Stephanis (`@georgestephanis`) with Claude
Code assistance, per the report's AI disclosure.
- `mypy` left unchecked: not part of the focused validation for this
change.
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
145 lines
4.2 KiB
Python
145 lines
4.2 KiB
Python
from __future__ import annotations
|
|
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
|
|
from tests._dotenv import importorskip_no_env_leak
|
|
|
|
importorskip_no_env_leak("litellm")
|
|
|
|
from headroom.backends.litellm import LiteLLMBackend # noqa: E402
|
|
|
|
|
|
class FakeAsyncStream:
|
|
def __init__(self, items) -> None: # noqa: ANN001
|
|
self._items = list(items)
|
|
|
|
def __aiter__(self):
|
|
self._iter = iter(self._items)
|
|
return self
|
|
|
|
async def __anext__(self):
|
|
try:
|
|
return next(self._iter)
|
|
except StopIteration as exc:
|
|
raise StopAsyncIteration from exc
|
|
|
|
|
|
def make_backend() -> LiteLLMBackend:
|
|
with patch("headroom.backends.litellm._fetch_bedrock_inference_profiles", return_value={}):
|
|
return LiteLLMBackend(provider="openrouter")
|
|
|
|
|
|
def make_response() -> SimpleNamespace:
|
|
return SimpleNamespace(
|
|
id="resp_123",
|
|
created=123456,
|
|
choices=[
|
|
SimpleNamespace(
|
|
index=0,
|
|
finish_reason="stop",
|
|
message=SimpleNamespace(role="assistant", content="ok", tool_calls=None),
|
|
)
|
|
],
|
|
usage=SimpleNamespace(prompt_tokens=2, completion_tokens=3, total_tokens=5),
|
|
)
|
|
|
|
|
|
def request_body(**overrides):
|
|
body = {
|
|
"model": "qwen3",
|
|
"messages": [{"role": "user", "content": "hello"}],
|
|
"max_tokens": 32,
|
|
}
|
|
body.update(overrides)
|
|
return body
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_chat_template_kwargs_forwarded_buffered() -> None:
|
|
backend = make_backend()
|
|
|
|
with patch("headroom.backends.litellm.acompletion", new_callable=AsyncMock) as mock_acomp:
|
|
mock_acomp.return_value = make_response()
|
|
|
|
await backend.send_openai_message(
|
|
request_body(chat_template_kwargs={"enable_thinking": False}),
|
|
{},
|
|
)
|
|
|
|
kwargs = mock_acomp.await_args.kwargs
|
|
assert kwargs["max_tokens"] == 32
|
|
assert kwargs["extra_body"] == {"chat_template_kwargs": {"enable_thinking": False}}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_chat_template_kwargs_forwarded_streaming() -> None:
|
|
backend = make_backend()
|
|
|
|
stream = FakeAsyncStream(
|
|
[
|
|
SimpleNamespace(model_dump=lambda **kwargs: {"id": "chunk1", "choices": []}),
|
|
]
|
|
)
|
|
|
|
with patch("headroom.backends.litellm.acompletion", new_callable=AsyncMock) as mock_acomp:
|
|
mock_acomp.return_value = stream
|
|
|
|
chunks = [
|
|
chunk
|
|
async for chunk in backend.stream_openai_message(
|
|
request_body(
|
|
chat_template_kwargs={"enable_thinking": False},
|
|
stream_options={"include_usage": True},
|
|
),
|
|
{},
|
|
)
|
|
]
|
|
|
|
kwargs = mock_acomp.await_args.kwargs
|
|
assert kwargs["stream"] is True
|
|
assert kwargs["stream_options"] == {"include_usage": True}
|
|
assert kwargs["extra_body"] == {"chat_template_kwargs": {"enable_thinking": False}}
|
|
assert chunks[-1] == "data: [DONE]\n\n"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_standard_only_body_has_no_extra_body() -> None:
|
|
backend = make_backend()
|
|
|
|
with patch("headroom.backends.litellm.acompletion", new_callable=AsyncMock) as mock_acomp:
|
|
mock_acomp.return_value = make_response()
|
|
|
|
await backend.send_openai_message(
|
|
request_body(temperature=0.1, top_p=0.9),
|
|
{},
|
|
)
|
|
|
|
kwargs = mock_acomp.await_args.kwargs
|
|
assert "extra_body" not in kwargs
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_standard_params_still_forwarded() -> None:
|
|
backend = make_backend()
|
|
|
|
with patch("headroom.backends.litellm.acompletion", new_callable=AsyncMock) as mock_acomp:
|
|
mock_acomp.return_value = make_response()
|
|
|
|
await backend.send_openai_message(
|
|
request_body(
|
|
temperature=0.1,
|
|
top_p=0.9,
|
|
response_format={"type": "json_object"},
|
|
chat_template_kwargs={"enable_thinking": False},
|
|
),
|
|
{},
|
|
)
|
|
|
|
kwargs = mock_acomp.await_args.kwargs
|
|
assert kwargs["temperature"] == 0.1
|
|
assert kwargs["top_p"] == 0.9
|
|
assert kwargs["response_format"] == {"type": "json_object"}
|
|
assert kwargs["extra_body"] == {"chat_template_kwargs": {"enable_thinking": False}}
|