Skip to content

Commit c6a1b50

Browse files
authored
Fix Codex OAuth request identity (#2647)
1 parent cb4fe70 commit c6a1b50

6 files changed

Lines changed: 94 additions & 17 deletions

File tree

hindsight-api-slim/hindsight_api/engine/providers/codex_llm.py

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,12 @@
5353

5454
logger = logging.getLogger(__name__)
5555

56+
# Newer Codex models are gated on the first-party client identity; the previous
57+
# browser-shaped User-Agent returned "Model not found" for Luna (#2643).
58+
# Use a neutral version because Hindsight must not claim a specific Codex release.
59+
_CODEX_ORIGINATOR = "codex_cli_rs"
60+
_CODEX_USER_AGENT = "codex_cli_rs/0.0.0 (Hindsight)"
61+
5662
# Name of the single forced function tool used to carry structured output when
5763
# strict_schema is on. The Codex backend speaks the OpenAI Responses API, so a
5864
# forced function call gives us constrained decoding straight into the response
@@ -187,6 +193,18 @@ def access_token(self, v: str) -> None:
187193
def account_id(self) -> str:
188194
return self._auth_manager.account_id
189195

196+
def _build_request_headers(self) -> httpx.Headers:
197+
return httpx.Headers(
198+
{
199+
"Authorization": f"Bearer {self.access_token}",
200+
"Content-Type": "application/json",
201+
"OpenAI-Account-ID": self.account_id,
202+
"User-Agent": _CODEX_USER_AGENT,
203+
"Origin": "https://chatgpt.com",
204+
"originator": _CODEX_ORIGINATOR,
205+
}
206+
)
207+
190208
@property
191209
def refresh_token(self) -> str | None:
192210
return self._auth_manager.refresh_token
@@ -475,13 +493,7 @@ async def call(
475493
payload["tool_choice"] = {"type": "function", "name": _STRUCTURED_TOOL_NAME}
476494
payload["parallel_tool_calls"] = False
477495

478-
headers = {
479-
"Authorization": f"Bearer {self.access_token}",
480-
"Content-Type": "application/json",
481-
"OpenAI-Account-ID": self.account_id,
482-
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
483-
"Origin": "https://chatgpt.com",
484-
}
496+
headers = self._build_request_headers()
485497

486498
url = f"{self.base_url}/codex/responses"
487499

@@ -843,13 +855,7 @@ async def call_with_tools(
843855
"prompt_cache_key": str(uuid.uuid4()),
844856
}
845857

846-
headers = {
847-
"Authorization": f"Bearer {self.access_token}",
848-
"Content-Type": "application/json",
849-
"OpenAI-Account-ID": self.account_id,
850-
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
851-
"Origin": "https://chatgpt.com",
852-
}
858+
headers = self._build_request_headers()
853859

854860
url = f"{self.base_url}/codex/responses"
855861

hindsight-api-slim/tests/test_codex_oauth_refresh.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@
2424
import asyncio
2525
import base64
2626
import json
27-
import os
2827
import stat
2928
import sys
3029
import time
@@ -422,6 +421,7 @@ async def test_call_reactively_refreshes_on_401_and_retries(tmp_path: Path):
422421
refresh_resp = _refresh_response(200, {"access_token": new_access, "refresh_token": "rt-new"})
423422

424423
call_count = {"refresh": 0, "post": 0}
424+
sent_headers: list[httpx.Headers] = []
425425

426426
# Sync mock for the auth manager's HTTP client (used for token refresh).
427427
def fake_refresh_post(*args, **kwargs):
@@ -431,6 +431,7 @@ def fake_refresh_post(*args, **kwargs):
431431
# Async mock for the LLM's HTTP client (used for backend calls).
432432
async def fake_backend_post(url, **kwargs):
433433
call_count["post"] += 1
434+
sent_headers.append(httpx.Headers(kwargs["headers"]))
434435
if call_count["post"] == 1:
435436
raise httpx.HTTPStatusError("401", request=MagicMock(), response=fail_response)
436437
return success_resp
@@ -451,6 +452,10 @@ async def fake_backend_post(url, **kwargs):
451452
assert call_count["refresh"] == 1
452453
assert call_count["post"] == 2 # one 401, one success after refresh
453454
assert llm.access_token == new_access
455+
assert sent_headers[0]["Authorization"] == f"Bearer {fresh}"
456+
assert sent_headers[1]["Authorization"] == f"Bearer {new_access}"
457+
for header_name in ("Content-Type", "OpenAI-Account-ID", "User-Agent", "Origin", "originator"):
458+
assert sent_headers[1][header_name] == sent_headers[0][header_name]
454459

455460

456461
@pytest.mark.asyncio
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""Regression tests for Codex request identity headers."""
2+
3+
from unittest.mock import AsyncMock, MagicMock, patch
4+
5+
import httpx
6+
import pytest
7+
8+
from hindsight_api.engine.providers.codex_llm import CodexLLM
9+
10+
11+
def build_llm() -> CodexLLM:
12+
with (
13+
patch.object(CodexLLM, "_load_codex_auth", return_value=("token", "account")),
14+
patch.object(CodexLLM, "_load_codex_refresh_token", return_value=None),
15+
):
16+
return CodexLLM(
17+
provider="openai-codex",
18+
api_key="ignored",
19+
base_url="https://chatgpt.com/backend-api",
20+
model="gpt-5.6-luna",
21+
)
22+
23+
24+
def assert_codex_request_identity(headers: httpx.Headers) -> None:
25+
assert headers["originator"] == "codex_cli_rs"
26+
assert headers["User-Agent"] == "codex_cli_rs/0.0.0 (Hindsight)"
27+
28+
29+
@pytest.mark.asyncio
30+
async def test_call_sends_codex_request_identity() -> None:
31+
llm = build_llm()
32+
response = MagicMock()
33+
response.raise_for_status.return_value = None
34+
35+
with (
36+
patch.object(llm._client, "post", new_callable=AsyncMock) as mock_post,
37+
patch.object(llm, "_parse_sse_stream", new_callable=AsyncMock, return_value="ok"),
38+
):
39+
mock_post.return_value = response
40+
await llm.call(messages=[{"role": "user", "content": "hello"}], max_retries=0)
41+
42+
assert_codex_request_identity(mock_post.call_args.kwargs["headers"])
43+
44+
45+
@pytest.mark.asyncio
46+
async def test_call_with_tools_sends_codex_request_identity() -> None:
47+
llm = build_llm()
48+
response = MagicMock()
49+
response.status_code = 200
50+
response.raise_for_status.return_value = None
51+
52+
with (
53+
patch.object(llm._client, "post", new_callable=AsyncMock) as mock_post,
54+
patch.object(llm, "_parse_sse_tool_stream", new_callable=AsyncMock, return_value=(None, [])),
55+
):
56+
mock_post.return_value = response
57+
await llm.call_with_tools(
58+
messages=[{"role": "user", "content": "hello"}],
59+
tools=[],
60+
max_retries=0,
61+
)
62+
63+
assert_codex_request_identity(mock_post.call_args.kwargs["headers"])

hindsight-api-slim/tests/test_codex_strict_schema.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,12 +100,15 @@ async def test_strict_schema_uses_forced_function_tool():
100100
max_retries=0,
101101
)
102102
sent_payload = mock_post.call_args.kwargs["json"]
103+
sent_headers = mock_post.call_args.kwargs["headers"]
103104

104105
# Forced tool wired into the request payload.
105106
assert sent_payload["tool_choice"] == {"type": "function", "name": "structured_response"}
106107
assert len(sent_payload["tools"]) == 1
107108
assert sent_payload["tools"][0]["name"] == "structured_response"
108109
assert sent_payload["parallel_tool_calls"] is False
110+
assert sent_headers["originator"] == "codex_cli_rs"
111+
assert sent_headers["User-Agent"] == "codex_cli_rs/0.0.0 (Hindsight)"
109112
# No prompt-injected schema in the instructions.
110113
assert "You must respond with valid JSON" not in sent_payload["instructions"]
111114

hindsight-docs/docs/developer/models.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,7 @@ Use your ChatGPT Plus or Pro subscription for Hindsight without separate OpenAI
261261
4. **Configure Hindsight:**
262262
```bash
263263
export HINDSIGHT_API_LLM_PROVIDER=openai-codex
264-
# export HINDSIGHT_API_LLM_MODEL=gpt-5.3-codex # defaults to gpt-5.4-mini
264+
# export HINDSIGHT_API_LLM_MODEL=gpt-5.6-luna # defaults to gpt-5.4-mini
265265
# No API key needed - reads from ~/.codex/auth.json automatically
266266
```
267267

skills/hindsight-docs/references/developer/models.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,7 @@ Use your ChatGPT Plus or Pro subscription for Hindsight without separate OpenAI
321321
4. **Configure Hindsight:**
322322
```bash
323323
export HINDSIGHT_API_LLM_PROVIDER=openai-codex
324-
# export HINDSIGHT_API_LLM_MODEL=gpt-5.3-codex # defaults to gpt-5.4-mini
324+
# export HINDSIGHT_API_LLM_MODEL=gpt-5.6-luna # defaults to gpt-5.4-mini
325325
# No API key needed - reads from ~/.codex/auth.json automatically
326326
```
327327

0 commit comments

Comments
 (0)