headroom/tests/test_proxy
Kirill 6c48ac81f2
fix(proxy): honor x-headroom-base-url in dedicated OpenAI handlers (#1502)
## Description

The dedicated OpenAI handlers (`/v1/chat/completions`, `/v1/responses`)
ignore the `x-headroom-base-url` request header that the opencode/CLI
transports already send on every routed request
(`plugins/opencode/src/transport.ts`) and that the generic passthrough
route already honors (`providers/proxy_routes.py:953`).

As a result, OpenAI-compatible gateways (LiteLLM, CPA, self-hosted vLLM,
Azure OpenAI) route correctly for passthrough traffic, but the dedicated
chat/responses handlers fall back to the default `OPENAI_API_URL` and
send the request — and the user's provider key — to the wrong upstream.
This forces OpenCode users behind a custom gateway to run a hand-rolled
plugin that re-spawns the proxy with `OPENAI_TARGET_API_URL` instead of
the supported `HeadroomPlugin`.

Refs #1503 (feature-request issue with full spec — API surface, failure
modes, security considerations).

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

Non-breaking: when the header is absent (the common case), behavior is
identical to before — `_resolve_openai_upstream` falls back to
`self.OPENAI_API_URL`.

## Changes Made

- Added `OpenAIHandlerMixin._resolve_openai_upstream(request)` — returns
`request.headers.get("x-headroom-base-url") or self.OPENAI_API_URL`.
Prefers the header, falls back to the configured URL.
- Used it at the two direct-path HTTP upstream sites:
- `handle_openai_chat` →
`build_copilot_upstream_url(self._resolve_openai_upstream(request),
"/v1/chat/completions")`
- `handle_openai_responses` →
`build_copilot_upstream_url(self._resolve_openai_upstream(request),
"/v1/responses")`
- This makes the dedicated handlers behave identically to the catch-all
passthrough and the Azure path (`_select_passthrough_base_url`,
`providers/proxy_routes.py:66,:953`), which already read the same
header.
- The header is already stripped before forwarding by
`helpers._strip_internal_headers`, so no upstream leakage /
fingerprinting is introduced.
- CHANGELOG entry under `### Bug Fixes`.

## Testing

<!-- Check what you actually ran, then paste the real command output
below. -->

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`) — not run locally (maturin
native build not available in my env; covered by CI)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

New `tests/test_proxy/test_openai_upstream_header.py` pins the
resolution contract (3 cases):

```text
$ pytest tests/test_proxy/test_openai_upstream_header.py -q
...
collected 3 items
tests/test_proxy/test_openai_upstream_header.py ...                      [100%]
========================= 3 passed, 1 warning in 0.25s =========================
```

Fail-before confirmed (unpatched handler raises `AttributeError:
_resolve_openai_upstream`):

```text
FAILED tests/test_proxy/test_openai_upstream_header.py::test_header_overrides_configured_url
FAILED tests/test_proxy/test_openai_upstream_header.py::test_missing_header_falls_back_to_configured_url
FAILED tests/test_proxy/test_openai_upstream_header.py::test_empty_header_falls_back_to_configured_url
========================= 3 failed, 1 warning in 0.29s =========================
```

Lint/format:

```text
$ ruff check headroom/proxy/handlers/openai.py tests/test_proxy/test_openai_upstream_header.py
Ruff: No issues found
$ ruff format --check headroom/proxy/handlers/openai.py tests/test_proxy/test_openai_upstream_header.py
2 files already formatted
```

## Real Behavior Proof

- Environment: macOS (darwin), Python 3.12 (pipx install of
`headroom-ai`), Headroom proxy `headroom proxy --port 8787` with
`OPENAI_TARGET_API_URL=https://cpa.funxyz.fun` (an OpenAI-compatible
gateway — "CLI Proxy API"). OpenCode with a custom `cpa` provider
(`@ai-sdk/openai-compatible`, `baseURL: https://cpa.funxyz.fun/v1`)
using the official `HeadroomPlugin`.
- Exact command / steps: traced the bug in the installed package source
— confirmed `handle_openai_chat` builds its upstream URL from
`self.OPENAI_API_URL` only (`proxy/handlers/openai.py:2487`), never
reading `x-headroom-base-url`, while `providers/proxy_routes.py:953`
reads it for passthrough. Then applied this patch and re-imported the
handler from the repo source via `PYTHONPATH`.
- Observed result: before the patch, `/v1/chat/completions` requests
ignored the `x-headroom-base-url: https://cpa.funxyz.fun` header (set by
the opencode transport) and routed to the default upstream, failing
against a non-OpenAI gateway — requiring a custom respawn-plugin
workaround. After the patch, `_resolve_openai_upstream` returns the
header value and the request forwards to the configured gateway; the
official `HeadroomPlugin` works without the env-var workaround. Unit
tests pass (3/3) and fail on the unpatched handler (3/3).
- Not tested: full `uv sync` CI matrix (native `headroom._core` maturin
build unavailable locally, so `headroom.proxy.server` import chain that
pulls `transforms/content_router` can't be exercised here — the edited
handler module imports fine and the focused unit tests exercise the new
method directly). WebSocket/Codex paths (`handle_openai_responses_ws`,
`_ws_http_fallback`) — intentionally out of scope (see Additional
Notes). `mypy headroom` — deferred to CI.

## 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 — no public
API/docs surface; the header is already documented as an internal
control flag in `helpers.py:1489-1495`
- [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

**Scope boundary — WebSocket paths intentionally unchanged.** The two WS
sites (`handle_openai_responses_ws`, `_ws_http_fallback`) are
Codex-specific and left as-is:

1. They short-circuit to `chatgpt.com` under ChatGPT-session auth (not
arbitrary gateways).
2. The WS path strips `x-headroom-base-url` from `upstream_headers`
(`_strip_internal`, ~line 3756) before the upstream URL is built, and
`_ws_http_fallback` receives already-stripped headers as a parameter.
Honoring the header there would require threading it through the WS
internals and changing a signature, for a path a custom
OpenAI-compatible WebSocket gateway is unlikely to use. The HTTP paths
cover the realistic gateway case. Happy to do it as a follow-up if
maintainers want it.

**Issue-first.** This is a behaviour change, so per CONTRIBUTING a
feature-request issue (#1503) is open for triage with the full spec (API
surface, user stories, failure modes, security). This PR implements it;
holding for maintainer 👍 before treating as ready to merge.

---------

Co-authored-by: ShutovKS <shutovks@example.local>
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-01 22:24:39 -05:00
..
test_anthropic_buffered_timeout.py fix(proxy): add an Anthropic buffered read-timeout override (#1331) 2026-06-23 22:46:31 -05:00
test_anthropic_ccr_deferred_injection.py fix: restore token-mode compression on frozen prefixes (#1489) 2026-06-28 13:21:52 -07:00
test_anthropic_ccr_raise.py fix(anthropic): CCR exception must re-raise, not silently swallow (#838) 2026-06-10 21:11:46 -05:00
test_anthropic_streaming_ccr_retrieve.py fix(proxy): handle streaming CCR retrieval (#1451) 2026-06-30 13:46:34 -05:00
test_background_compression.py perf(compression): take large cold-start contexts off the synchronous kompress path (#1171) (#1298) 2026-06-23 10:48:06 -05:00
test_bedrock_passthrough.py feat(proxy): compress AWS Bedrock InvokeModel requests via configurable upstream (#720) 2026-06-16 14:56:16 -05:00
test_cc_switch_reconciler.py feat(proxy): cc-switch reconciler — keep Headroom in the request path alongside cc-switch (#1030) 2026-06-17 08:41:16 -05:00
test_ccr_frozen_prefix_coupling.py fix(proxy): stop re-compressing headroom_retrieve output and emitting unredeemable markers (#1323) 2026-06-25 10:11:42 -05:00
test_compression_failure_action.py fix(codex): fail open for proxy compression timeout 2026-06-04 14:04:49 +05:30
test_compression_timeout_config.py feat(proxy): make COMPRESSION_TIMEOUT_SECONDS configurable via env (#946) (#991) 2026-06-16 21:10:01 -05:00
test_compute_turn_id.py fix(proxy): strip cache_control before hashing turn_id 2026-04-23 16:04:57 +02:00
test_header_safe_transforms.py feat(transforms): attribute read_lifecycle + smart_crush tags (#249) 2026-06-11 11:51:26 -05:00
test_mcp_stats_aggregation.py fix(proxy): Strands MCP bundle + backend path fixes + Codex fail-closed protection 2026-05-21 11:00:14 -07:00
test_openai_backend_path.py fix(proxy): Strands MCP bundle + backend path fixes + Codex fail-closed protection 2026-05-21 11:00:14 -07:00
test_openai_transport_path_prefix.py fix(opencode): preserve custom OpenAI gateway paths (#1596) 2026-06-30 08:41:42 -07:00
test_openai_upstream_header.py fix(proxy): honor x-headroom-base-url in dedicated OpenAI handlers (#1502) 2026-07-01 22:24:39 -05:00
test_phase3_byte_identity.py perf(compression): take large cold-start contexts off the synchronous kompress path (#1171) (#1298) 2026-06-23 10:48:06 -05:00
test_request_logger.py feat(proxy): log compressed messages alongside original request (#261) 2026-06-11 19:02:54 -05:00
test_transformations_feed.py fix(proxy): scope CORS to loopback + gate operator/content endpoints (#1226) 2026-06-21 00:50:55 -07:00