mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
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:
parent
4e5a67a342
commit
a540eb2c61
2 changed files with 167 additions and 0 deletions
|
|
@ -7,13 +7,17 @@ import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import FastAPI, Request, WebSocket
|
from fastapi import FastAPI, Request, WebSocket
|
||||||
|
from fastapi.responses import Response
|
||||||
|
|
||||||
from headroom.providers.cloudcode import normalize_cloudcode_passthrough_path
|
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 (
|
from headroom.providers.codex.live import (
|
||||||
CODEX_LIVE_ROUTE_PATHS,
|
CODEX_LIVE_ROUTE_PATHS,
|
||||||
handle_codex_live_websocket,
|
handle_codex_live_websocket,
|
||||||
)
|
)
|
||||||
from headroom.providers.codex.responses import handle_chatgpt_codex_responses_subpath
|
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 (
|
from headroom.providers.model_metadata import (
|
||||||
MODEL_METADATA_LIST_ENDPOINT,
|
MODEL_METADATA_LIST_ENDPOINT,
|
||||||
handle_model_metadata_endpoint,
|
handle_model_metadata_endpoint,
|
||||||
|
|
@ -67,6 +71,32 @@ from headroom.proxy.request_scope import normalize_request_path
|
||||||
logger = logging.getLogger("headroom.proxy.routes")
|
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(
|
def _register_provider_passthrough_route(
|
||||||
app: FastAPI,
|
app: FastAPI,
|
||||||
proxy: Any,
|
proxy: Any,
|
||||||
|
|
@ -456,6 +486,16 @@ def register_provider_routes(app: FastAPI, proxy: Any) -> None:
|
||||||
provider_name=provider_name,
|
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_openai_image_routes(app, proxy)
|
||||||
|
|
||||||
_register_codex_live_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
|
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:
|
def test_proxy_route_helpers_prefer_legacy_targets_and_gemini_passthrough() -> None:
|
||||||
proxy_routes = importlib.import_module("headroom.providers.proxy_routes")
|
proxy_routes = importlib.import_module("headroom.providers.proxy_routes")
|
||||||
proxy = type(
|
proxy = type(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue