mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
fix: normalize /p/<project> prefix on WebSocket upgrades so the Responses WS route is not rejected with 403 (#2379)
## Description
A Responses WebSocket upgrade to a project-prefixed URL
(`ws://127.0.0.1:8787/p/<project>/v1/responses`) was rejected with `403
Forbidden`, so the client fell back to HTTP transport. The `/p/<name>`
base-URL prefix is stripped by
`strip_project_path_prefix(request.scope)` inside
`@app.middleware("http")`, but Starlette runs `@app.middleware("http")`
for `http` scopes only, never `websocket` scopes. So an HTTP `POST
/p/<project>/v1/responses` has its prefix stripped and matches
`/v1/responses`, while the WS upgrade keeps the prefix, matches no
registered WebSocket route (`OPENAI_RESPONSES_WEBSOCKET_PATHS` are all
unprefixed), and Starlette rejects the unmatched WebSocket with `403`.
This normalizes the prefix for WebSocket scopes before routing so the
upgrade reaches the existing Responses WS handler and stays attributed
to the project.
Closes #2355
## 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/server.py` — added a small pure-ASGI
`WebSocketProjectPrefixMiddleware` (registered in `create_app`) that,
for `websocket` scopes only, strips the `/p/<name>` prefix via the
existing `strip_project_path_prefix` and binds the project context,
mirroring the HTTP middleware. HTTP and lifespan scopes pass through
untouched (no double-strip).
- `headroom/proxy/handlers/openai.py` — `handle_openai_responses_ws`
previously called `set_current_project(classify_project(ws_headers))`
unconditionally, clearing the middleware-bound project for prefix-only
clients (no `X-Headroom-Project` header). It now falls back to the
already-bound path-prefix project (`classify_project(ws_headers) or
get_current_project()`), so prefix-only WebSocket clients (aider,
Copilot BYOK, Cursor and other `/p/<name>` base-URL wraps) stay
attributed, exactly as on the HTTP path.
- `tests/test_provider_proxy_routes.py` — added a regression test.
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed
### Test Output
```text
$ pytest tests/test_provider_proxy_routes.py -q
21 passed, 1 warning in 23.95s
$ ruff check headroom/proxy/server.py headroom/proxy/handlers/openai.py
All checks passed!
$ mypy --python-version 3.13 headroom/proxy/server.py headroom/proxy/handlers/openai.py
Success: no issues found in 2 source files
```
## Real Behavior Proof
- Environment: local, `uv` venv, Python 3.14, `uv run pytest`.
- Exact command / steps: added
`test_project_prefixed_openai_response_websocket_delegates_to_openai_ws_handler`,
which connects a WebSocket to `/p/test-project/v1/responses`.
- Observed result: the connection is accepted (no 403), the handler is
reached with the canonical `/v1/responses` path, and the request is
attributed to project `test-project`.
- Not tested: live end-to-end against a real upstream Responses
WebSocket server (validated via the routing/attribution regression test
only).
## 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
- [x] I did **not** edit `CHANGELOG.md` — it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)
## Screenshots (if applicable)
N/A — backend routing change with no user-facing UI.
## Additional Notes
Documentation checklist item is N/A: this is an internal routing fix
with no configuration or public-API surface change. The fix mirrors the
existing HTTP prefix-strip behavior so project-prefixed WebSocket
clients behave identically to their HTTP counterparts.
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
This commit is contained in:
parent
f9db5b5060
commit
789a4f3060
3 changed files with 53 additions and 3 deletions
|
|
@ -75,7 +75,11 @@ from headroom.proxy.outcome import RequestOutcome
|
||||||
from headroom.proxy.passthrough import (
|
from headroom.proxy.passthrough import (
|
||||||
custom_base_passthrough_telemetry as _custom_base_passthrough_telemetry,
|
custom_base_passthrough_telemetry as _custom_base_passthrough_telemetry,
|
||||||
)
|
)
|
||||||
from headroom.proxy.project_context import classify_project, set_current_project
|
from headroom.proxy.project_context import (
|
||||||
|
classify_project,
|
||||||
|
get_current_project,
|
||||||
|
set_current_project,
|
||||||
|
)
|
||||||
from headroom.proxy.token_counting import gemini_output_tokens
|
from headroom.proxy.token_counting import gemini_output_tokens
|
||||||
|
|
||||||
logger = logging.getLogger("headroom.proxy")
|
logger = logging.getLogger("headroom.proxy")
|
||||||
|
|
@ -5726,8 +5730,11 @@ class OpenAIHandlerMixin:
|
||||||
# Captured in closure so per-turn RequestOutcome can stamp it.
|
# Captured in closure so per-turn RequestOutcome can stamp it.
|
||||||
client = classify_client(ws_headers)
|
client = classify_client(ws_headers)
|
||||||
# WS sessions bypass the HTTP middleware, so bind the project here;
|
# WS sessions bypass the HTTP middleware, so bind the project here;
|
||||||
# per-turn outcome emission inside this task inherits the context.
|
# per-turn outcome emission inside this task inherits the context. An
|
||||||
set_current_project(classify_project(ws_headers))
|
# explicit X-Headroom-Project header wins; otherwise fall back to the
|
||||||
|
# /p/<name> path prefix already bound by WebSocketProjectPrefixMiddleware
|
||||||
|
# so prefix-only clients (aider, Copilot BYOK, Cursor) stay attributed.
|
||||||
|
set_current_project(classify_project(ws_headers) or get_current_project())
|
||||||
metrics_for_inbound_ws = getattr(self, "metrics", None)
|
metrics_for_inbound_ws = getattr(self, "metrics", None)
|
||||||
if metrics_for_inbound_ws is not None and hasattr(
|
if metrics_for_inbound_ws is not None and hasattr(
|
||||||
metrics_for_inbound_ws, "record_inbound_request"
|
metrics_for_inbound_ws, "record_inbound_request"
|
||||||
|
|
|
||||||
|
|
@ -2364,6 +2364,22 @@ _is_known_websocket_callback_failure = is_known_websocket_callback_failure
|
||||||
_tool_schema_saved_from_tags = tool_schema_saved_from_tags
|
_tool_schema_saved_from_tags = tool_schema_saved_from_tags
|
||||||
|
|
||||||
|
|
||||||
|
class WebSocketProjectPrefixMiddleware:
|
||||||
|
"""Normalize project-prefixed WebSocket paths before route matching."""
|
||||||
|
|
||||||
|
def __init__(self, app: Any) -> None:
|
||||||
|
self.app = app
|
||||||
|
|
||||||
|
async def __call__(self, scope: Any, receive: Any, send: Any) -> None:
|
||||||
|
if scope["type"] == "websocket":
|
||||||
|
prefix_project = strip_project_path_prefix(scope)
|
||||||
|
headers = {
|
||||||
|
name.decode("latin-1"): value.decode("latin-1") for name, value in scope["headers"]
|
||||||
|
}
|
||||||
|
set_current_project(classify_project(headers) or prefix_project)
|
||||||
|
await self.app(scope, receive, send)
|
||||||
|
|
||||||
|
|
||||||
def create_app(config: ProxyConfig | None = None) -> FastAPI:
|
def create_app(config: ProxyConfig | None = None) -> FastAPI:
|
||||||
"""Create FastAPI application."""
|
"""Create FastAPI application."""
|
||||||
if not FASTAPI_AVAILABLE:
|
if not FASTAPI_AVAILABLE:
|
||||||
|
|
@ -2573,6 +2589,7 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI:
|
||||||
version=__version__,
|
version=__version__,
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
app.add_middleware(WebSocketProjectPrefixMiddleware)
|
||||||
loop_health_state: LoopHealthState = {
|
loop_health_state: LoopHealthState = {
|
||||||
"status": "healthy",
|
"status": "healthy",
|
||||||
"known_failures": 0,
|
"known_failures": 0,
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ from fastapi.responses import JSONResponse
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from headroom.providers.codex.runtime import CodexRoutingDecision
|
from headroom.providers.codex.runtime import CodexRoutingDecision
|
||||||
|
from headroom.proxy.project_context import get_current_project
|
||||||
from headroom.proxy.server import HeadroomProxy, ProxyConfig, create_app
|
from headroom.proxy.server import HeadroomProxy, ProxyConfig, create_app
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -481,6 +482,31 @@ def test_openai_response_websocket_aliases_delegate_to_openai_ws_handler(monkeyp
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_prefixed_openai_response_websocket_delegates_to_openai_ws_handler(
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
seen_paths: list[str] = []
|
||||||
|
seen_projects: list[str | None] = []
|
||||||
|
|
||||||
|
async def fake_ws(self, websocket): # type: ignore[no-untyped-def]
|
||||||
|
seen_paths.append(websocket.url.path)
|
||||||
|
seen_projects.append(get_current_project())
|
||||||
|
await websocket.accept()
|
||||||
|
await websocket.send_json({"path": websocket.url.path})
|
||||||
|
await websocket.close()
|
||||||
|
|
||||||
|
monkeypatch.setattr(HeadroomProxy, "handle_openai_responses_ws", fake_ws)
|
||||||
|
|
||||||
|
with TestClient(_app()) as client:
|
||||||
|
with client.websocket_connect("/p/test-project/v1/responses") as websocket:
|
||||||
|
assert websocket.receive_json() == {"path": "/v1/responses"}
|
||||||
|
|
||||||
|
assert seen_paths == ["/v1/responses"]
|
||||||
|
# The /p/<name> prefix is bound as the project even without a header, so a
|
||||||
|
# prefix-only Codex WS client is still attributed (not just routed).
|
||||||
|
assert seen_projects == ["test-project"]
|
||||||
|
|
||||||
|
|
||||||
def test_openai_response_subpath_passthrough_returns_502_on_http_failure() -> None:
|
def test_openai_response_subpath_passthrough_returns_502_on_http_failure() -> None:
|
||||||
class FailingAsyncClient:
|
class FailingAsyncClient:
|
||||||
async def request(self, method, url, **kwargs): # type: ignore[no-untyped-def]
|
async def request(self, method, url, **kwargs): # type: ignore[no-untyped-def]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue