headroom/tests/test_litellm_openai_passthrough.py
Rod Boev fb683e18d4
fix(litellm): forward chat_template_kwargs and other vendor top-level fields to OpenAI-compatible backends via extra_body (#2128) (#2163)
## 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>
2026-07-14 12:00:19 -04:00

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}}