mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
## 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.
93 lines
3.6 KiB
Python
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"
|