fix(codex): route alpha search through the Codex backend (#2538)

## Description

Codex GPT-5.6 standalone web search currently falls through Headroom's
generic passthrough path. Under ChatGPT OAuth that sends `POST
/v1/alpha/search` to `https://chatgpt.com/v1/alpha/search`, which
redirects to HTML and makes Codex fail to decode the response.

This change adds an explicit standalone Codex search alias so
ChatGPT-authenticated `/v1/alpha/search` requests route through
`https://chatgpt.com/backend-api/codex/alpha/search`, while non-ChatGPT
traffic keeps the existing passthrough behavior. Closes #2525.

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

- add a dedicated `POST /v1/alpha/search` Codex route for
ChatGPT-authenticated traffic
- route that alias through the existing `codex_backend_url()` helper so
the upstream path becomes `/backend-api/codex/alpha/search`
- add focused regression coverage for ChatGPT-auth routing and
non-ChatGPT passthrough preservation

## Testing

- [x] Unit tests pass (`uv run pytest
tests/test_provider_proxy_routes.py -q`)
- [x] Linting passes (`uv run ruff check
headroom/providers/proxy_routes.py tests/test_provider_proxy_routes.py`)
- [ ] Type checking passes (`uv run mypy headroom`)
- [x] New tests added for new functionality when applicable
- [x] Manual testing performed

### Test Output

```text
uv run pytest tests/test_provider_proxy_routes.py -q
23 passed, 1 warning in 15.77s

uv run ruff check headroom/providers/proxy_routes.py tests/test_provider_proxy_routes.py
All checks passed!

uv run ruff format headroom/providers/proxy_routes.py tests/test_provider_proxy_routes.py --check
2 files already formatted

git diff --check
(no output)
```

## Real Behavior Proof

- Environment: focused Headroom worktree with proxy route regression
tests
- Exact command / steps: run the issue-shaped inline Python reproduction
from `bodies/headroom-issue-2525.json`, then run the focused
preservation and matrix pytest rows for ChatGPT-auth and non-ChatGPT
auth
- Observed result: the base repro printed `FAIL issue2525 codex alpha
search -> observed_url=None fallback=[('/v1/alpha/search',
'https://chatgpt.com')]
body={"base_url":"https://chatgpt.com","provider":""}`, while the head
repro printed `PASS issue2525 codex alpha search ->
https://chatgpt.com/backend-api/codex/alpha/search?query=weather`; the
non-ChatGPT preservation row passed `1 passed, 22 deselected`, and the
auth matrix row passed `1 passed, 22 deselected`
- Not tested: live ChatGPT OAuth account on this host

## 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
- [x] 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 have updated the CHANGELOG.md if applicable

## Screenshots (if applicable)

N/A - proxy routing change only.

## Additional Notes

- `CHANGELOG.md` stays untouched because Headroom's release automation
generates it from conventional commits.
- The fix is scoped to standalone Codex search. It does not change
`/v1/responses`, image routes, or generic OpenAI passthrough semantics.
- Proof artifact:
`D:\Repos\.claude\pr-sweep\headroom-PR-TARGET-2525-PROOF.md`

Co-authored-by: JD Davis <jd@jds-macbook-air.tail2a279.ts.net>
This commit is contained in:
Rod Boev
2026-08-12 01:22:20 -04:00
committed by GitHub
parent 4e5a67a342
commit a540eb2c61
2 changed files with 167 additions and 0 deletions
+40
View File
@@ -7,13 +7,17 @@ import logging
from typing import Any
from fastapi import FastAPI, Request, WebSocket
from fastapi.responses import Response
from headroom.providers.cloudcode import normalize_cloudcode_passthrough_path
from headroom.providers.codex.endpoints import codex_backend_url
from headroom.providers.codex.headers import drop_header
from headroom.providers.codex.live import (
CODEX_LIVE_ROUTE_PATHS,
handle_codex_live_websocket,
)
from headroom.providers.codex.responses import handle_chatgpt_codex_responses_subpath
from headroom.providers.codex.runtime import resolve_codex_routing
from headroom.providers.model_metadata import (
MODEL_METADATA_LIST_ENDPOINT,
handle_model_metadata_endpoint,
@@ -67,6 +71,32 @@ from headroom.proxy.request_scope import normalize_request_path
logger = logging.getLogger("headroom.proxy.routes")
async def _handle_chatgpt_codex_alpha_search(request: Request, proxy: Any) -> Response | None:
upstream_headers = dict(request.headers.items())
drop_header(upstream_headers, "host")
drop_header(upstream_headers, "accept-encoding")
from headroom.proxy.helpers import _strip_internal_headers
decision = resolve_codex_routing(_strip_internal_headers(upstream_headers))
if not decision.is_chatgpt_auth:
return None
body = await request.body()
assert proxy.http_client is not None
resp = await proxy.http_client.request(
request.method,
codex_backend_url("/alpha/search", request.url.query),
headers=decision.headers,
content=body,
timeout=120.0,
)
return Response(
content=resp.content,
status_code=resp.status_code,
headers=dict(resp.headers),
)
def _register_provider_passthrough_route(
app: FastAPI,
proxy: Any,
@@ -456,6 +486,16 @@ def register_provider_routes(app: FastAPI, proxy: Any) -> None:
provider_name=provider_name,
)
@app.post("/v1/alpha/search")
async def codex_alpha_search(request: Request):
chatgpt_response = await _handle_chatgpt_codex_alpha_search(request, proxy)
if chatgpt_response is not None:
return chatgpt_response
return await proxy.handle_passthrough(
request,
_select_passthrough_base_url(proxy, dict(request.headers)),
)
_register_openai_image_routes(app, proxy)
_register_codex_live_routes(app, proxy)
+127
View File
@@ -274,6 +274,133 @@ def test_provider_passthrough_routes_forward_expected_targets(monkeypatch) -> No
assert len(anthropic_calls) >= 2
def test_codex_alpha_search_route_from_headroom_issue_2525() -> None:
class FakeAsyncClient:
def __init__(self) -> None:
self.calls: list[tuple[str, str, dict[str, str], bytes]] = []
async def request(self, method, url, **kwargs): # type: ignore[no-untyped-def]
self.calls.append(
(
method,
url,
dict(kwargs.get("headers", {})),
kwargs.get("content", b""),
)
)
return httpx.Response(200, json={"ok": True})
async def aclose(self) -> None:
return None
with TestClient(_app()) as client:
fake_http_client = FakeAsyncClient()
client.app.state.proxy.http_client = fake_http_client
response = client.post(
"/v1/alpha/search?query=weather",
headers={
"Authorization": "Bearer oauth-token",
"ChatGPT-Account-ID": "acct_123",
},
json={"query": "weather"},
)
assert response.status_code == 200
assert response.json() == {"ok": True}
assert len(fake_http_client.calls) == 1
method, url, headers, body = fake_http_client.calls[0]
assert method == "POST"
assert url == "https://chatgpt.com/backend-api/codex/alpha/search?query=weather"
assert headers["authorization"] == "Bearer oauth-token"
assert headers["chatgpt-account-id"] == "acct_123"
assert headers["content-length"] == "19"
assert headers["content-type"] == "application/json"
assert body == b'{"query":"weather"}'
def test_non_chatgpt_alpha_search_falls_through_to_openai_upstream(monkeypatch) -> None:
calls: list[tuple[str, str, str, str, str]] = []
async def fake_passthrough(self, request, base_url, sub_path="", provider_name=""): # type: ignore[no-untyped-def]
calls.append((request.method, request.url.path, base_url, sub_path, provider_name))
return JSONResponse(
{
"base_url": base_url,
"sub_path": sub_path,
"provider": provider_name,
}
)
monkeypatch.setattr(HeadroomProxy, "handle_passthrough", fake_passthrough)
with TestClient(_app()) as client:
response = client.post(
"/v1/alpha/search",
headers={"Authorization": "Bearer sk-proj-openai-test"},
json={"query": "weather"},
)
assert response.status_code == 200
assert response.json() == {
"base_url": "https://api.openai.test",
"sub_path": "",
"provider": "",
}
assert calls == [
(
"POST",
"/v1/alpha/search",
"https://api.openai.test",
"",
"",
)
]
def test_codex_alpha_search_route_matrix(monkeypatch) -> None:
fallback_calls: list[tuple[str, str]] = []
async def fake_passthrough(self, request, base_url, sub_path="", provider_name=""): # type: ignore[no-untyped-def]
fallback_calls.append((request.url.path, base_url))
return JSONResponse({"base_url": base_url, "provider": provider_name})
monkeypatch.setattr(HeadroomProxy, "handle_passthrough", fake_passthrough)
class FakeAsyncClient:
def __init__(self) -> None:
self.urls: list[str] = []
async def request(self, method, url, **kwargs): # type: ignore[no-untyped-def]
self.urls.append(url)
return httpx.Response(200, json={"ok": True})
async def aclose(self) -> None:
return None
with TestClient(_app()) as client:
fake_http_client = FakeAsyncClient()
client.app.state.proxy.http_client = fake_http_client
oauth_response = client.post(
"/v1/alpha/search",
headers={
"Authorization": "Bearer oauth-token",
"ChatGPT-Account-ID": "acct_123",
},
json={"query": "weather"},
)
api_key_response = client.post(
"/v1/alpha/search",
headers={"Authorization": "Bearer sk-proj-openai-test"},
json={"query": "weather"},
)
assert oauth_response.status_code == 200
assert api_key_response.status_code == 200
assert fake_http_client.urls == ["https://chatgpt.com/backend-api/codex/alpha/search"]
assert fallback_calls == [("/v1/alpha/search", "https://api.openai.test")]
def test_proxy_route_helpers_prefer_legacy_targets_and_gemini_passthrough() -> None:
proxy_routes = importlib.import_module("headroom.providers.proxy_routes")
proxy = type(