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:
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user