2026-04-17 17:45:08 +00:00
|
|
|
import base64
|
|
|
|
|
import json
|
|
|
|
|
|
2026-04-08 21:22:05 -05:00
|
|
|
import httpx
|
2026-04-17 23:09:12 +00:00
|
|
|
import pytest
|
2026-04-08 20:59:50 -05:00
|
|
|
from fastapi import WebSocket
|
2026-04-07 06:47:15 -05:00
|
|
|
from fastapi.responses import JSONResponse
|
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
|
|
|
|
from headroom.proxy.server import HeadroomProxy, ProxyConfig, create_app
|
|
|
|
|
|
|
|
|
|
|
2026-04-17 17:45:08 +00:00
|
|
|
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)}."
|
|
|
|
|
|
|
|
|
|
|
2026-04-08 20:59:50 -05:00
|
|
|
def test_codex_responses_aliases_delegate_to_openai_handler(monkeypatch):
|
2026-04-07 06:47:15 -05:00
|
|
|
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:
|
2026-04-17 17:45:08 +00:00
|
|
|
for path in (
|
|
|
|
|
"/v1/codex/responses",
|
|
|
|
|
"/backend-api/responses",
|
|
|
|
|
"/backend-api/codex/responses",
|
|
|
|
|
):
|
2026-04-08 20:59:50 -05:00
|
|
|
response = client.post(path, json={"model": "gpt-5.3-codex"})
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
assert response.json() == {"ok": True, "path": path}
|
2026-04-07 06:47:15 -05:00
|
|
|
|
2026-04-08 20:59:50 -05:00
|
|
|
|
|
|
|
|
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:
|
2026-04-17 17:45:08 +00:00
|
|
|
for path in (
|
|
|
|
|
"/v1/codex/responses",
|
|
|
|
|
"/backend-api/responses",
|
|
|
|
|
"/backend-api/codex/responses",
|
|
|
|
|
):
|
2026-04-08 20:59:50 -05:00
|
|
|
with client.websocket_connect(path) as websocket:
|
|
|
|
|
assert websocket.receive_json() == {"ok": True, "path": path}
|
|
|
|
|
|
2026-04-17 17:45:08 +00:00
|
|
|
assert seen_paths == [
|
|
|
|
|
"/v1/codex/responses",
|
|
|
|
|
"/backend-api/responses",
|
|
|
|
|
"/backend-api/codex/responses",
|
|
|
|
|
]
|
2026-04-08 21:22:05 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
|
2026-04-17 17:45:08 +00:00
|
|
|
pi_response = client.post(
|
|
|
|
|
"/v1/codex/responses/compact?trace=0",
|
|
|
|
|
json={"model": "gpt-5.3-codex"},
|
|
|
|
|
)
|
2026-04-08 21:22:05 -05:00
|
|
|
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"},
|
|
|
|
|
)
|
|
|
|
|
|
2026-04-17 17:45:08 +00:00
|
|
|
assert pi_response.status_code == 200
|
2026-04-08 21:22:05 -05:00
|
|
|
assert api_key_response.status_code == 200
|
|
|
|
|
assert chatgpt_response.status_code == 200
|
|
|
|
|
assert fake_http_client.calls == [
|
2026-04-17 17:45:08 +00:00
|
|
|
("POST", "https://api.openai.test/v1/responses/compact?trace=0"),
|
2026-04-08 21:22:05 -05:00
|
|
|
("POST", "https://api.openai.test/v1/responses/compact?trace=1"),
|
|
|
|
|
("POST", "https://chatgpt.com/backend-api/codex/responses/compact?trace=2"),
|
|
|
|
|
]
|
2026-04-17 17:45:08 +00:00
|
|
|
|
|
|
|
|
|
2026-04-17 23:09:12 +00:00
|
|
|
@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",
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
)
|
2026-04-20 19:19:43 -05:00
|
|
|
def test_codex_responses_subpath_passthrough_derives_chatgpt_routing_from_jwt(path, expected_url):
|
2026-04-17 17:45:08 +00:00
|
|
|
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(
|
2026-04-17 23:09:12 +00:00
|
|
|
path,
|
2026-04-17 17:45:08 +00:00
|
|
|
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"
|
2026-04-17 23:09:12 +00:00
|
|
|
assert url == expected_url
|
2026-04-17 17:45:08 +00:00
|
|
|
assert headers["authorization"] == f"Bearer {token}"
|
|
|
|
|
assert headers["ChatGPT-Account-ID"] == "acct-from-jwt"
|
2026-05-09 22:36:56 -07:00
|
|
|
|
|
|
|
|
|
2026-06-01 14:10:15 -06:00
|
|
|
def test_codex_model_metadata_fetches_codex_registry_for_chatgpt_auth(monkeypatch):
|
2026-05-15 17:54:24 -07:00
|
|
|
"""Issue #478: under Codex ChatGPT-subscription OAuth, the proxy
|
|
|
|
|
must NOT forward `/v1/models[/{id}]` to chatgpt.com/backend-api —
|
2026-06-01 14:10:15 -06:00
|
|
|
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.
|
2026-05-15 17:54:24 -07:00
|
|
|
"""
|
|
|
|
|
|
2026-05-09 22:36:56 -07:00
|
|
|
class FakeAsyncClient:
|
|
|
|
|
def __init__(self):
|
|
|
|
|
self.calls: list[tuple[str, str, dict[str, str]]] = []
|
|
|
|
|
|
2026-06-01 14:10:15 -06:00
|
|
|
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"}]},
|
|
|
|
|
)
|
2026-05-09 22:36:56 -07:00
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
|
2026-05-15 17:54:24 -07:00
|
|
|
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(
|
2026-06-01 14:10:15 -06:00
|
|
|
"/v1/models/gpt-99-future?client_version=0.130.0",
|
2026-05-15 17:54:24 -07:00
|
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
|
|
|
)
|
2026-05-09 22:36:56 -07:00
|
|
|
|
2026-06-01 14:10:15 -06:00
|
|
|
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
|
2026-05-09 22:36:56 -07:00
|
|
|
|
2026-05-15 17:54:24 -07:00
|
|
|
# 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
|
2026-06-01 14:10:15 -06:00
|
|
|
assert {entry["id"] for entry in list_payload["data"]} == {
|
|
|
|
|
"gpt-5.5",
|
|
|
|
|
"gpt-5.3-codex-spark",
|
|
|
|
|
}
|
2026-06-16 21:19:22 +02:00
|
|
|
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
|
2026-05-15 17:54:24 -07:00
|
|
|
|
|
|
|
|
# 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",
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 14:10:15 -06:00
|
|
|
# Unknown model variants 404 against the dynamic registry.
|
2026-05-15 17:54:24 -07:00
|
|
|
assert unknown_response.status_code == 404
|
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 18:45:20 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
_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"
|