headroom/tests/test_proxy_codex_route_aliases.py
gglucass b0cd0329c7
fix(proxy): stamp X-Client: codex on Responses endpoint for unidentified callers (#1036)
## Description

Codex Desktop (OpenAI's Codex GUI/IDE app) sends a `User-Agent` of the
form `Codex Desktop/<ver> (...)`, which is not in `CLIENT_UA_MAP`, so
`classify_client` returns `None`. On a compression timeout the backend
only takes the codex fail-open path when the client classifies as
`codex`; for an unidentified client it refuses with HTTP 413
(`compression_refused`), which Codex treats as a hard connection
failure.

This stamps `X-Client: codex` on requests to the Responses endpoint
(`/v1/responses`) only when the caller does not otherwise classify. The
stamp is scoped to the Responses endpoint and skipped for any caller
that already classifies through a recognized user-agent or explicit
`X-Client`, so non-Codex traffic is not relabeled.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [x] Tests only

## Changes Made

- Added `should_stamp_codex_client(path, headers)` in
`headroom.proxy.auth_mode` for narrow Responses-endpoint client
stamping.
- Applied the stamp in HTTP middleware before downstream request
classification.
- Applied the same stamp in the Responses WebSocket handler, which
bypasses HTTP middleware.
- Added unit coverage for the stamp/skip matrix, including Codex
Desktop, explicit clients, recognized user-agents, and WebSocket
behavior.
- Merged current `main` and kept both the new stamp coverage and the
existing Codex WebSocket image-generation regression coverage.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check`)
- [x] Formatting passes (`ruff format --check`)
- [x] New tests added for new functionality

### Test Output

```text
python -m pytest tests/test_codex_client_stamp.py tests/test_auth_mode.py tests/test_openai_codex_ws_lifecycle.py -q
48 passed in 1.13s

ruff check headroom/proxy/auth_mode.py headroom/proxy/server.py headroom/proxy/handlers/openai.py tests/test_codex_client_stamp.py tests/test_auth_mode.py tests/test_openai_codex_ws_lifecycle.py
All checks passed!

ruff format --check headroom/proxy/auth_mode.py headroom/proxy/server.py headroom/proxy/handlers/openai.py tests/test_codex_client_stamp.py tests/test_auth_mode.py tests/test_openai_codex_ws_lifecycle.py
6 files already formatted
```

## Real Behavior Proof

- Environment: local Windows 11 development checkout, Python 3.13.13,
branch updated from `upstream/main`.
- Exact command / steps: Ran the focused unit suite for the new
client-stamp behavior and the overlapping Codex WebSocket lifecycle
tests, then ran `ruff check` and `ruff format --check` on the changed
modules and tests.
- Observed result: The focused suite passed with 48 tests, lint passed,
and formatting passed. The tests assert that unidentified
`/v1/responses` callers classify as Codex after stamping while explicit
or already-recognized clients are preserved.
- Not tested: a live end-to-end Codex Desktop session through a running
`headroom wrap codex` instance; verification is at the unit/integration
boundary for classification and request routing.

## Review Readiness

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-06-17 11:45:20 -05:00

300 lines
11 KiB
Python

import base64
import json
import httpx
import pytest
from fastapi import WebSocket
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
from headroom.proxy.server import HeadroomProxy, ProxyConfig, create_app
def _jwt(payload: dict) -> str:
header = {"alg": "none", "typ": "JWT"}
def encode(part: dict) -> str:
raw = json.dumps(part, separators=(",", ":")).encode("utf-8")
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
return f"{encode(header)}.{encode(payload)}."
def test_codex_responses_aliases_delegate_to_openai_handler(monkeypatch):
async def fake_handle(self, request): # type: ignore[no-untyped-def]
return JSONResponse({"ok": True, "path": request.url.path})
monkeypatch.setattr(HeadroomProxy, "handle_openai_responses", fake_handle)
with TestClient(create_app(ProxyConfig())) as client:
for path in (
"/v1/codex/responses",
"/backend-api/responses",
"/backend-api/codex/responses",
):
response = client.post(path, json={"model": "gpt-5.3-codex"})
assert response.status_code == 200
assert response.json() == {"ok": True, "path": path}
def test_codex_responses_websocket_aliases_delegate_to_openai_handler(monkeypatch):
seen_paths: list[str] = []
async def fake_handle_ws(self, websocket: WebSocket): # type: ignore[no-untyped-def]
seen_paths.append(websocket.url.path)
await websocket.accept()
await websocket.send_json({"ok": True, "path": websocket.url.path})
await websocket.close()
monkeypatch.setattr(HeadroomProxy, "handle_openai_responses_ws", fake_handle_ws)
with TestClient(create_app(ProxyConfig())) as client:
for path in (
"/v1/codex/responses",
"/backend-api/responses",
"/backend-api/codex/responses",
):
with client.websocket_connect(path) as websocket:
assert websocket.receive_json() == {"ok": True, "path": path}
assert seen_paths == [
"/v1/codex/responses",
"/backend-api/responses",
"/backend-api/codex/responses",
]
def test_codex_responses_subpath_aliases_delegate_to_passthrough():
class FakeAsyncClient:
def __init__(self) -> None:
self.calls: list[tuple[str, str]] = []
async def request(self, method, url, **_kwargs): # type: ignore[no-untyped-def]
self.calls.append((method, url))
return httpx.Response(200, json={"method": method, "url": url})
async def aclose(self) -> None:
return None
with TestClient(create_app(ProxyConfig())) as client:
fake_http_client = FakeAsyncClient()
client.app.state.proxy.http_client = fake_http_client
client.app.state.proxy.OPENAI_API_URL = "https://api.openai.test"
pi_response = client.post(
"/v1/codex/responses/compact?trace=0",
json={"model": "gpt-5.3-codex"},
)
api_key_response = client.post(
"/backend-api/responses/compact?trace=1",
json={"model": "gpt-5.3-codex"},
)
chatgpt_response = client.post(
"/backend-api/codex/responses/compact?trace=2",
headers={"chatgpt-account-id": "acct_123"},
json={"model": "gpt-5.3-codex"},
)
assert pi_response.status_code == 200
assert api_key_response.status_code == 200
assert chatgpt_response.status_code == 200
assert fake_http_client.calls == [
("POST", "https://api.openai.test/v1/responses/compact?trace=0"),
("POST", "https://api.openai.test/v1/responses/compact?trace=1"),
("POST", "https://chatgpt.com/backend-api/codex/responses/compact?trace=2"),
]
@pytest.mark.parametrize(
("path", "expected_url"),
[
(
"/v1/codex/responses/compact?trace=jwt",
"https://chatgpt.com/backend-api/codex/responses/compact?trace=jwt",
),
(
"/v1/responses/compact?trace=jwt-old",
"https://chatgpt.com/backend-api/codex/responses/compact?trace=jwt-old",
),
],
)
def test_codex_responses_subpath_passthrough_derives_chatgpt_routing_from_jwt(path, expected_url):
class FakeAsyncClient:
def __init__(self) -> None:
self.calls: list[tuple[str, str, dict[str, str]]] = []
async def request(self, method, url, **kwargs): # type: ignore[no-untyped-def]
self.calls.append((method, url, dict(kwargs.get("headers", {}))))
return httpx.Response(200, json={"method": method, "url": url})
async def aclose(self) -> None:
return None
token = _jwt(
{
"https://api.openai.com/auth": {
"chatgpt_account_id": "acct-from-jwt",
}
}
)
with TestClient(create_app(ProxyConfig())) as client:
fake_http_client = FakeAsyncClient()
client.app.state.proxy.http_client = fake_http_client
client.app.state.proxy.OPENAI_API_URL = "https://api.openai.test"
response = client.post(
path,
headers={"Authorization": f"Bearer {token}"},
json={"model": "gpt-5.4"},
)
assert response.status_code == 200
assert len(fake_http_client.calls) == 1
method, url, headers = fake_http_client.calls[0]
assert method == "POST"
assert url == expected_url
assert headers["authorization"] == f"Bearer {token}"
assert headers["ChatGPT-Account-ID"] == "acct-from-jwt"
def test_codex_model_metadata_fetches_codex_registry_for_chatgpt_auth(monkeypatch):
"""Issue #478: under Codex ChatGPT-subscription OAuth, the proxy
must NOT forward `/v1/models[/{id}]` to chatgpt.com/backend-api —
that endpoint returns 403 to OAuth tokens. Instead, Headroom should
fetch the Codex-specific model registry and synthesize an
OpenAI-compatible payload from its slugs.
"""
class FakeAsyncClient:
def __init__(self):
self.calls: list[tuple[str, str, dict[str, str]]] = []
async def get(self, url, **kwargs): # type: ignore[no-untyped-def]
self.calls.append(("GET", url, dict(kwargs.get("headers", {}))))
return httpx.Response(
200,
json={"models": [{"slug": "gpt-5.5"}, {"slug": "gpt-5.3-codex-spark"}]},
)
async def aclose(self):
return None
token = _jwt(
{
"https://api.openai.com/auth": {
"chatgpt_account_id": "acct-from-jwt",
}
}
)
with TestClient(create_app(ProxyConfig())) as client:
fake_http_client = FakeAsyncClient()
client.app.state.proxy.http_client = fake_http_client
client.app.state.proxy.OPENAI_API_URL = "https://api.openai.test"
list_response = client.get(
"/v1/models?client_version=0.130.0",
headers={"Authorization": f"Bearer {token}"},
)
known_response = client.get(
"/v1/models/gpt-5.5",
headers={"Authorization": f"Bearer {token}"},
)
unknown_response = client.get(
"/v1/models/gpt-99-future?client_version=0.130.0",
headers={"Authorization": f"Bearer {token}"},
)
assert len(fake_http_client.calls) == 3
for method, url, headers in fake_http_client.calls:
assert method == "GET"
assert url == "https://chatgpt.com/backend-api/codex/models?client_version=0.130.0"
assert headers["authorization"] == f"Bearer {token}"
assert headers["chatgpt-account-id"] == "acct-from-jwt"
assert headers["accept"] == "application/json"
assert "ChatGPT-Account-ID" not in headers
assert "Accept" not in headers
# List endpoint returns a non-empty OpenAI-compatible list.
assert list_response.status_code == 200
list_payload = list_response.json()
assert list_payload["object"] == "list"
assert isinstance(list_payload["data"], list)
assert len(list_payload["data"]) > 0
assert {entry["id"] for entry in list_payload["data"]} == {
"gpt-5.5",
"gpt-5.3-codex-spark",
}
assert {entry["slug"] for entry in list_payload["models"]} == {
"gpt-5.5",
"gpt-5.3-codex-spark",
}
for entry in list_payload["models"]:
assert entry["display_name"]
assert entry["default_reasoning_level"] == "medium"
assert entry["supports_parallel_tool_calls"] is True
# Single-model GET returns a model object when known.
assert known_response.status_code == 200
known_payload = known_response.json()
assert known_payload == {
"id": "gpt-5.5",
"object": "model",
"created": 0,
"owned_by": "openai",
}
# Unknown model variants 404 against the dynamic registry.
assert unknown_response.status_code == 404
_CODEX_DESKTOP_UA = (
"Codex Desktop/0.140.0-alpha.2 (Mac OS 15.7.7; arm64) unknown (Codex Desktop; 26.609.71450)"
)
def test_responses_middleware_stamps_x_client_codex_for_unidentified_caller(monkeypatch):
# Codex Desktop's User-Agent isn't a known codex UA, so the HTTP middleware
# must stamp X-Client: codex on /v1/responses before the handler classifies
# the caller — otherwise a compression timeout is refused with a 413 that
# Codex treats as a hard connection failure.
seen: dict[str, str | None] = {}
async def fake_handle(self, request): # type: ignore[no-untyped-def]
seen["x-client"] = request.headers.get("x-client")
return JSONResponse({"ok": True})
monkeypatch.setattr(HeadroomProxy, "handle_openai_responses", fake_handle)
with TestClient(create_app(ProxyConfig())) as client:
response = client.post(
"/v1/responses",
headers={"user-agent": _CODEX_DESKTOP_UA},
json={"model": "gpt-5.3-codex"},
)
assert response.status_code == 200
assert seen["x-client"] == "codex"
def test_responses_middleware_preserves_explicit_x_client(monkeypatch):
# A caller that already self-identifies is left untouched by the stamp.
seen: dict[str, str | None] = {}
async def fake_handle(self, request): # type: ignore[no-untyped-def]
seen["x-client"] = request.headers.get("x-client")
return JSONResponse({"ok": True})
monkeypatch.setattr(HeadroomProxy, "handle_openai_responses", fake_handle)
with TestClient(create_app(ProxyConfig())) as client:
response = client.post(
"/v1/responses",
headers={"x-client": "aider", "user-agent": _CODEX_DESKTOP_UA},
json={"model": "gpt-5.3-codex"},
)
assert response.status_code == 200
assert seen["x-client"] == "aider"