headroom/tests/test_proxy/test_openai_upstream_header.py
Tejas Chopra 2976d49f18
fix(proxy): preserve sub-path in X-Headroom-Base-Url custom upstream (#2037) (#2127)
## Description

`_resolve_openai_upstream_base` ran the `X-Headroom-Base-Url` value
through `_normalize_origin`, which strips the path. A custom
OpenAI-compatible upstream served from a sub-path, such as
`https://host/api/v1`, was routed to the bare origin and returned
`proxy_error` (#2037). This re-attaches the path after origin
normalization.

This is a clean extraction of the path fix from #2047, which bundled it
with an unrelated `supports_websockets = true` to `false` default change
across init/wrap/codex. #2047 can be closed in favor of this narrower
fix.

Closes #2037

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

- `headroom/proxy/handlers/openai.py`: re-attach the request header path
component in `_resolve_openai_upstream_base` after origin normalization.
- `tests/test_proxy/test_openai_upstream_header.py`: assert sub-paths
are preserved and trailing slashes are normalized.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
$ pytest tests/test_proxy/test_openai_upstream_header.py
5 passed

$ ruff check headroom/proxy/handlers/openai.py tests/test_proxy/test_openai_upstream_header.py
All checks passed!
```

## Real Behavior Proof

- Environment: local proxy header resolution path, custom
OpenAI-compatible upstream configured via `X-Headroom-Base-Url`.
- Exact command / steps: resolve `X-Headroom-Base-Url:
https://gateway.example/api/v1` through `_resolve_openai_upstream_base`
/ `_resolve_openai_upstream`.
- Observed result: before the fix, the upstream resolved to
`https://gateway.example` and lost `/api/v1`, causing the proxy to route
to the wrong endpoint. After the fix, it resolves to
`https://gateway.example/api/v1`; a trailing slash is normalized away.
- Not tested: end-to-end request against a live third-party
OpenAI-compatible gateway. The regression is covered at the proxy
routing helper layer where the path was dropped.

## 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
- [ ] 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
- [ ] I have updated the CHANGELOG.md if applicable

## Screenshots (if applicable)

N/A.

## Additional Notes

- Documentation is not updated because this fixes the existing header
behavior rather than changing a documented user-facing contract.
- Changelog is not updated in this PR; the change is scoped to the
regression and test.
- `mypy headroom` was not run in the author's workflow.
2026-07-13 19:53:45 -04:00

93 lines
3.6 KiB
Python

"""Tests for ``OpenAIHandlerMixin._resolve_openai_upstream``.
The dedicated OpenAI handlers (``/v1/chat/completions``,
``/v1/responses``) must honor the ``x-headroom-base-url`` request header
so OpenAI-compatible gateways (LiteLLM, CPA, self-hosted vLLM, Azure
OpenAI) route correctly — consistent with the generic passthrough route
that already honors it (see ``providers/proxy_routes.py``).
These tests pin the resolution contract:
- header present → its value wins
- header absent → configured ``OPENAI_API_URL`` fallback
- header empty or whitespace-only → fallback (no blanking)
"""
from __future__ import annotations
import pytest
fastapi = pytest.importorskip("fastapi")
httpx = pytest.importorskip("httpx")
from starlette.datastructures import Headers # noqa: E402
from headroom.proxy.handlers.openai import OpenAIHandlerMixin # noqa: E402
class _FakeRequest:
"""Minimal stand-in exposing ``headers`` like a real Starlette request.
Uses ``starlette.datastructures.Headers`` so header lookup is
case-insensitive, matching the production ``request.headers`` — a
plain ``dict`` would let case-folding regressions pass silently.
"""
def __init__(self, headers: dict[str, str]) -> None:
self.headers = Headers(headers=headers)
def _stub_proxy(fallback_url: str) -> OpenAIHandlerMixin:
"""A bare mixin instance with only ``OPENAI_API_URL`` configured."""
return type( # type: ignore[return-value]
"_S",
(OpenAIHandlerMixin,),
{"OPENAI_API_URL": fallback_url},
)()
def test_header_overrides_configured_url() -> None:
proxy = _stub_proxy("https://api.openai.test")
# The transport sends the upstream origin (no /v1 path).
request = _FakeRequest({"x-headroom-base-url": "https://gateway.example"})
assert proxy._resolve_openai_upstream(request) == "https://gateway.example"
def test_missing_header_falls_back_to_configured_url() -> None:
proxy = _stub_proxy("https://api.openai.test")
request = _FakeRequest({})
assert proxy._resolve_openai_upstream(request) == "https://api.openai.test"
def test_empty_header_falls_back_to_configured_url() -> None:
"""An explicitly empty or whitespace-only header must not blank the upstream."""
proxy = _stub_proxy("https://api.openai.test")
empty = _FakeRequest({"x-headroom-base-url": ""})
assert proxy._resolve_openai_upstream(empty) == "https://api.openai.test"
whitespace = _FakeRequest({"x-headroom-base-url": " "})
assert proxy._resolve_openai_upstream(whitespace) == "https://api.openai.test"
def test_header_lookup_is_case_insensitive() -> None:
"""Transports may send mixed-case header names; lookup must still resolve."""
proxy = _stub_proxy("https://api.openai.test")
# Real transports routinely send Title-Case header names.
request = _FakeRequest({"X-Headroom-Base-Url": "https://gateway.example"})
assert proxy._resolve_openai_upstream(request) == "https://gateway.example"
def test_header_with_subpath_preserves_path() -> None:
"""A custom upstream served from a sub-path (e.g. /api/v1) must keep the path,
not be collapsed to the bare origin (#2047)."""
proxy = _stub_proxy("https://api.openai.test")
request = _FakeRequest({"x-headroom-base-url": "https://gateway.example/api/v1"})
assert proxy._resolve_openai_upstream(request) == "https://gateway.example/api/v1"
# Trailing slash is normalized away, not doubled.
trailing = _FakeRequest({"x-headroom-base-url": "https://gateway.example/api/v1/"})
assert proxy._resolve_openai_upstream(trailing) == "https://gateway.example/api/v1"