headroom/tests/test_openai_streaming_backend.py
Rod Boev d6a1af40d5
fix(proxy): skip max_tokens rename for backend-routed openai chat (#2401)
## Description

OpenAI-format `POST /v1/chat/completions` requests routed through
`--backend litellm-vertex` fail when the client includes `max_tokens`.
The proxy currently runs its direct-OpenAI compatibility shim before
backend dispatch, renames `max_tokens` to `max_completion_tokens`, then
the LiteLLM path no longer recognizes that field as standard and sweeps
it into `extra_body`. Vertex rejects the resulting request with
`extra_body: Extra inputs are not permitted`.

This change scopes the rename shim to the direct OpenAI path only.
Backend-routed chat requests now keep `max_tokens`, which LiteLLM
already forwards correctly for the Vertex Anthropic path. Direct GPT-5
and o-series compatibility stays unchanged. Closes #2392.

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

- Thread a backend-owned translation flag into
`_normalize_openai_max_tokens`.
- Skip the legacy-to-completion-token rename on backend-routed OpenAI
chat requests.
- Keep the direct OpenAI compatibility path covered with a backend-owned
translation no-op test.
- Add buffered and streaming handler-level regressions for the exact
`litellm-vertex` request shape, proving the request survives the
`/v1/chat/completions` normalization boundary with vendor fields intact.

## Testing

- [x] Unit tests pass (`uv run pytest
tests/test_proxy/test_openai_backend_path.py
tests/test_openai_streaming_backend.py
tests/test_openai_max_completion_tokens.py
tests/test_litellm_openai_passthrough.py -q`)
- [x] Linting passes (`uv run ruff check
headroom/proxy/handlers/openai.py
tests/test_openai_max_completion_tokens.py
tests/test_litellm_openai_passthrough.py
tests/test_proxy/test_openai_backend_path.py
tests/test_openai_streaming_backend.py`)
- [ ] 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_proxy/test_openai_backend_path.py tests/test_openai_streaming_backend.py tests/test_openai_max_completion_tokens.py tests/test_litellm_openai_passthrough.py -q
......sss............                                                    [100%]
20 passed, 3 skipped, 1 warning in 42.13s

$ uv run ruff check headroom/proxy/handlers/openai.py tests/test_openai_max_completion_tokens.py tests/test_litellm_openai_passthrough.py tests/test_proxy/test_openai_backend_path.py tests/test_openai_streaming_backend.py
All checks passed!

$ uv run ruff format headroom/proxy/handlers/openai.py tests/test_openai_max_completion_tokens.py tests/test_litellm_openai_passthrough.py tests/test_proxy/test_openai_backend_path.py tests/test_openai_streaming_backend.py --check
5 files already formatted
```

## Real Behavior Proof

- Environment: Windows, synced Headroom development environment, mocked
LiteLLM provider boundary, no paid GCP credentials required
- Exact command / steps: run `uv run pytest
tests/test_proxy/test_openai_backend_path.py
tests/test_openai_streaming_backend.py
tests/test_openai_max_completion_tokens.py
tests/test_litellm_openai_passthrough.py -q`, using the issue payload
shape
`{"model":"claude-sonnet-4-6","max_tokens":32,"messages":[{"role":"user","content":"hi"}],"chat_template_kwargs":{"enable_thinking":false}}`
through `POST /v1/chat/completions`
- Observed result: buffered and streaming `litellm-vertex` requests keep
`max_tokens` as a named backend kwarg, preserve `chat_template_kwargs`
in `extra_body`, omit `max_completion_tokens` from `extra_body`, and
return success through the handler boundary. Direct-path normalization
still renames legacy `max_tokens`.
- Not tested: live Vertex AI request

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [ ] My code follows the project's style guidelines
- [ ] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] 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
- [ ] I have updated the CHANGELOG.md if applicable

## Additional Notes

- `CHANGELOG.md`: N/A, the release pipeline generates it from the
conventional-commit subject.
- Scope is intentionally narrow: this fixes the exact backend-routed
`max_tokens` failure and does not broaden `extra_body` hardening for
unrelated OpenAI fields.
2026-07-18 16:47:10 -07:00

309 lines
12 KiB
Python

"""Test OpenAI /v1/chat/completions streaming through headroom proxy backends.
Proves that streaming works end-to-end: client → headroom proxy → backend → OpenAI API.
Two test modes:
1. Real API test (requires OPENAI_API_KEY): hits actual OpenAI with gpt-4o-mini
2. Mock test: proves the proxy returns SSE when stream:true with a backend configured
Run with:
OPENAI_API_KEY=sk-... pytest tests/test_openai_streaming_backend.py -v
"""
import os
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
fastapi = pytest.importorskip("fastapi")
httpx = pytest.importorskip("httpx")
from fastapi.testclient import TestClient # noqa: E402
from headroom.backends.base import BackendResponse # noqa: E402
from headroom.proxy.server import ProxyConfig, create_app # noqa: E402
# =============================================================================
# Real API test (requires OPENAI_API_KEY)
# =============================================================================
@pytest.mark.skipif(not os.environ.get("OPENAI_API_KEY"), reason="OPENAI_API_KEY not set")
class TestOpenAIStreamingRealAPI:
"""Test streaming with real OpenAI API calls through the proxy."""
@pytest.fixture
def openai_api_key(self):
return os.environ["OPENAI_API_KEY"]
@pytest.fixture
def direct_proxy_client(self):
"""Proxy with NO backend — direct to OpenAI. This is the baseline."""
config = ProxyConfig(
optimize=False,
cache_enabled=False,
rate_limit_enabled=False,
)
app = create_app(config)
with TestClient(app) as client:
yield client
@pytest.fixture
def litellm_backend_client(self):
"""Proxy with litellm-openai backend — routes through LiteLLM."""
config = ProxyConfig(
optimize=False,
cache_enabled=False,
rate_limit_enabled=False,
backend="litellm-openai",
)
app = create_app(config)
with TestClient(app) as client:
yield client
def test_baseline_streaming_works_direct(self, direct_proxy_client, openai_api_key):
"""Baseline: streaming through proxy WITHOUT backend works (direct to OpenAI)."""
response = direct_proxy_client.post(
"/v1/chat/completions",
json={
"model": "gpt-4o-mini",
"messages": [{"role": "user", "content": "Say 'hello' and nothing else."}],
"stream": True,
"max_tokens": 10,
},
headers={"Authorization": f"Bearer {openai_api_key}"},
)
assert response.status_code == 200, f"Got {response.status_code}: {response.text[:200]}"
content_type = response.headers.get("content-type", "")
assert "text/event-stream" in content_type, (
f"Direct proxy streaming broken: got content-type '{content_type}'"
)
# Verify we got actual SSE chunks
body = response.text
assert "data: " in body, "No SSE data chunks in response"
assert "data: [DONE]" in body, "Missing [DONE] terminator"
def test_streaming_with_litellm_backend(self, litellm_backend_client, openai_api_key):
"""CRITICAL: streaming through proxy WITH litellm backend must also stream.
This test fails before the fix — the proxy returns a JSON blob
instead of SSE events, causing clients to hang.
"""
response = litellm_backend_client.post(
"/v1/chat/completions",
json={
"model": "gpt-4o-mini",
"messages": [{"role": "user", "content": "Say 'hello' and nothing else."}],
"stream": True,
"max_tokens": 10,
},
headers={"Authorization": f"Bearer {openai_api_key}"},
)
assert response.status_code == 200, f"Got {response.status_code}: {response.text[:200]}"
content_type = response.headers.get("content-type", "")
assert "text/event-stream" in content_type, (
f"STREAMING BUG: litellm backend returned '{content_type}' instead of "
f"'text/event-stream'. Client sees a JSON blob, not SSE events.\n"
f"Response body (first 300 chars): {response.text[:300]}"
)
# Verify SSE format
body = response.text
assert "data: " in body, "No SSE data chunks in streaming response"
def test_non_streaming_with_litellm_backend(self, litellm_backend_client, openai_api_key):
"""Non-streaming with backend should return normal JSON (sanity check)."""
response = litellm_backend_client.post(
"/v1/chat/completions",
json={
"model": "gpt-4o-mini",
"messages": [{"role": "user", "content": "Say 'hello' and nothing else."}],
"stream": False,
"max_tokens": 10,
},
headers={"Authorization": f"Bearer {openai_api_key}"},
)
assert response.status_code == 200, f"Got {response.status_code}: {response.text[:200]}"
content_type = response.headers.get("content-type", "")
assert "application/json" in content_type
data = response.json()
assert "choices" in data
assert data["choices"][0]["message"]["content"]
# =============================================================================
# Mock test (no API key needed — proves the routing bug)
# =============================================================================
class TestOpenAIStreamingMock:
"""Prove the streaming bug with mocks — no API key needed."""
def test_streaming_request_returns_sse_not_json(self):
"""When stream:true with a backend, content-type MUST be text/event-stream.
This test FAILS before the fix: the proxy calls send_openai_message()
(non-streaming) and returns application/json even though stream:true.
"""
config = ProxyConfig(
optimize=False,
cache_enabled=False,
rate_limit_enabled=False,
backend="anyllm",
anyllm_provider="openai",
)
mock_backend = MagicMock()
mock_backend.name = "anyllm-openai"
mock_backend.send_openai_message = AsyncMock(
return_value=BackendResponse(
body={
"id": "chatcmpl-123",
"object": "chat.completion",
"model": "test-model",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "Hello!"},
"finish_reason": "stop",
}
],
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
},
status_code=200,
headers={"content-type": "application/json"},
)
)
with patch("headroom.proxy.server.AnyLLMBackend", return_value=mock_backend):
app = create_app(config)
with TestClient(app) as client:
response = client.post(
"/v1/chat/completions",
json={
"model": "test-model",
"messages": [{"role": "user", "content": "hello"}],
"stream": True,
},
headers={"Authorization": "Bearer test-key"},
)
assert response.status_code == 200, (
f"Got {response.status_code}: {response.text[:200]}"
)
content_type = response.headers.get("content-type", "")
assert "text/event-stream" in content_type, (
f"STREAMING BUG: stream:true with backend returned '{content_type}' "
f"instead of 'text/event-stream'. The proxy ignored the stream flag "
f"and returned a JSON blob. Clients expecting SSE will hang.\n"
f"Response: {response.text[:300]}"
)
def test_non_streaming_still_returns_json(self):
"""Sanity: stream:false with backend should return JSON as before."""
config = ProxyConfig(
optimize=False,
cache_enabled=False,
rate_limit_enabled=False,
backend="anyllm",
anyllm_provider="openai",
)
mock_backend = MagicMock()
mock_backend.name = "anyllm-openai"
mock_backend.send_openai_message = AsyncMock(
return_value=BackendResponse(
body={
"id": "chatcmpl-123",
"object": "chat.completion",
"model": "test-model",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "Hello!"},
"finish_reason": "stop",
}
],
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
},
status_code=200,
headers={"content-type": "application/json"},
)
)
with patch("headroom.proxy.server.AnyLLMBackend", return_value=mock_backend):
app = create_app(config)
with TestClient(app) as client:
response = client.post(
"/v1/chat/completions",
json={
"model": "test-model",
"messages": [{"role": "user", "content": "hello"}],
"stream": False,
},
headers={"Authorization": "Bearer test-key"},
)
assert response.status_code == 200
content_type = response.headers.get("content-type", "")
assert "application/json" in content_type
data = response.json()
assert data["choices"][0]["message"]["content"] == "Hello!"
def test_litellm_vertex_streaming_preserves_max_tokens_and_vendor_fields(self):
config = ProxyConfig(
optimize=False,
cache_enabled=False,
rate_limit_enabled=False,
backend="litellm-vertex",
)
async def fake_stream():
yield SimpleNamespace(
model_dump=lambda **kwargs: {
"id": "chunk1",
"choices": [{"delta": {"content": "a"}}],
}
)
with (
patch("headroom.backends.litellm._fetch_bedrock_inference_profiles", return_value={}),
patch("headroom.backends.litellm.acompletion", new_callable=AsyncMock) as mock_acomp,
):
mock_acomp.return_value = fake_stream()
app = create_app(config)
with TestClient(app) as client:
response = client.post(
"/v1/chat/completions",
json={
"model": "claude-sonnet-4-6",
"messages": [{"role": "user", "content": "hi"}],
"max_tokens": 32,
"chat_template_kwargs": {"enable_thinking": False},
"stream": True,
},
headers={"Authorization": "Bearer test-key"},
)
assert response.status_code == 200, response.text
assert "text/event-stream" in response.headers.get("content-type", "")
assert "data: [DONE]" in response.text
kwargs = mock_acomp.await_args.kwargs
assert kwargs["stream"] is True
assert kwargs["max_tokens"] == 32
assert kwargs["extra_body"] == {"chat_template_kwargs": {"enable_thinking": False}}
assert "max_completion_tokens" not in kwargs["extra_body"]