feat(proxy): add /chatgpt passthrough for Codex Sign in with ChatGPT

This commit is contained in:
mateo-berri 2026-07-25 18:34:32 -07:00
parent 998a372417
commit 01cf5748d6
4 changed files with 455 additions and 0 deletions

View file

@ -421,6 +421,7 @@ class LiteLLMRoutes(enum.Enum):
"/bedrock",
"/vertex-ai",
"/vertex_ai",
"/chatgpt",
"/cohere",
"/cursor",
"/gemini",

View file

@ -24,6 +24,7 @@ from litellm.constants import (
BEDROCK_AGENT_RUNTIME_PASS_THROUGH_ROUTES,
)
from litellm.llms.anthropic.common_utils import AnthropicModelInfo
from litellm.llms.chatgpt.common_utils import CHATGPT_API_BASE
from litellm.llms.vertex_ai.vertex_llm_base import VertexBase
from litellm.proxy._types import *
from litellm.proxy.auth.route_checks import RouteChecks
@ -2023,6 +2024,83 @@ class BaseOpenAIPassThroughHandler:
return joined_path_str
CHATGPT_CREDENTIAL_COOKIE = "litellm_api_key"
CHATGPT_STRIPPED_REQUEST_HEADERS = frozenset({"cookie", "x-litellm-api-key", "content-length", "host"})
def get_chatgpt_gateway_credential(request: Request) -> str | None:
header_credential = request.headers.get("x-litellm-api-key")
if header_credential:
return header_credential
return request.cookies.get(CHATGPT_CREDENTIAL_COOKIE)
def build_chatgpt_forward_headers(request: Request) -> dict[str, str]:
return {
header_name: header_value
for header_name, header_value in _safe_get_request_headers(request).items()
if header_name.lower() not in CHATGPT_STRIPPED_REQUEST_HEADERS
}
@router.api_route(
"/chatgpt/{endpoint:path}",
methods=["GET", "POST", "PUT", "DELETE", "PATCH"],
tags=["ChatGPT Pass-through", "pass-through"],
)
async def chatgpt_proxy_route(
endpoint: str,
request: Request,
fastapi_response: Response,
):
"""
Passthrough for Codex clients signed in with ChatGPT (SIWC).
The LiteLLM virtual key arrives in the `x-litellm-api-key` header or the
`litellm_api_key` cookie and is stripped before forwarding. The
`Authorization` header carries the ChatGPT bearer, which is forwarded
unchanged (along with `ChatGPT-Account-ID`) to
https://chatgpt.com/backend-api/codex.
"""
gateway_credential = get_chatgpt_gateway_credential(request)
if gateway_credential is None:
raise HTTPException(
status_code=401,
detail=(
"LiteLLM virtual key not found. Send it in the 'x-litellm-api-key' header or the "
f"'{CHATGPT_CREDENTIAL_COOKIE}' cookie. The 'Authorization' header is reserved for the "
"ChatGPT bearer token and is forwarded upstream unchanged."
),
)
user_api_key_dict = await user_api_key_auth(request=request, api_key=f"Bearer {gateway_credential}")
base_target_url = os.getenv("CHATGPT_API_BASE") or CHATGPT_API_BASE
encoded_endpoint = httpx.URL(endpoint).path
if not encoded_endpoint.startswith("/"):
encoded_endpoint = "/" + encoded_endpoint
base_url = httpx.URL(base_target_url)
updated_url = base_url.copy_with(
path=HttpPassThroughEndpointHelpers.join_base_and_endpoint_path(base_url, encoded_endpoint)
)
is_streaming_request = await is_streaming_request_fn(request)
endpoint_func = create_pass_through_route(
endpoint=endpoint,
target=str(updated_url),
custom_headers=build_chatgpt_forward_headers(request),
custom_llm_provider=LlmProviders.CHATGPT.value,
is_streaming_request=is_streaming_request,
)
return await endpoint_func(
request,
fastapi_response,
user_api_key_dict,
)
@router.api_route(
"/cursor/{endpoint:path}",
methods=["GET", "POST", "PUT", "DELETE", "PATCH"],

View file

@ -3022,3 +3022,158 @@ class TestCursorProxyRoute:
assert call_args["target"] == "https://api.cursor.com/v0/agents"
assert result["id"] == "bc_abc123"
assert result["status"] == "CREATING"
class TestChatGPTProxyRoute:
"""
Tests for the Codex Sign-in-with-ChatGPT (SIWC) passthrough (/chatgpt)
"""
def _build_request(self, headers: dict, cookies: dict, body: dict) -> MagicMock:
mock_request = MagicMock(spec=Request)
mock_request.method = "POST"
mock_request.headers = headers
mock_request.cookies = cookies
mock_request.query_params = {}
return mock_request
def _codex_headers(self, extra: dict) -> dict:
return {
"content-type": "application/json",
"authorization": "Bearer chatgpt-oauth-access-token",
"chatgpt-account-id": "acct-uuid-123",
"originator": "codex_cli_rs",
"session_id": "sess-uuid-456",
"host": "localhost:4000",
"content-length": "100",
**extra,
}
async def _call_route(self, mock_request: MagicMock, body: dict):
from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import (
chatgpt_proxy_route,
)
mock_response = MagicMock(spec=Response)
with (
patch(
"litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.user_api_key_auth",
new_callable=AsyncMock,
return_value=UserAPIKeyAuth(api_key="hashed"),
) as mock_auth,
patch(
"litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints._read_request_body",
new_callable=AsyncMock,
return_value=body,
),
patch(
"litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.create_pass_through_route"
) as mock_create_route,
):
mock_endpoint_func = AsyncMock(return_value={"id": "resp_1"})
mock_create_route.return_value = mock_endpoint_func
result = await chatgpt_proxy_route(
endpoint="responses",
request=mock_request,
fastapi_response=mock_response,
)
return result, mock_auth, mock_create_route
@pytest.mark.asyncio
async def test_auth_from_x_litellm_api_key_header_and_forwarding(self):
body = {"model": "gpt-5.3-codex", "stream": True}
mock_request = self._build_request(
headers=self._codex_headers({"x-litellm-api-key": "sk-litellm-virtual-key"}),
cookies={},
body=body,
)
result, mock_auth, mock_create_route = await self._call_route(mock_request, body)
mock_auth.assert_called_once()
assert mock_auth.call_args.kwargs["api_key"] == "Bearer sk-litellm-virtual-key"
call_args = mock_create_route.call_args[1]
assert call_args["target"] == "https://chatgpt.com/backend-api/codex/responses"
assert call_args["custom_llm_provider"] == "chatgpt"
assert call_args["is_streaming_request"] is True
forwarded = call_args["custom_headers"]
assert forwarded["authorization"] == "Bearer chatgpt-oauth-access-token"
assert forwarded["chatgpt-account-id"] == "acct-uuid-123"
assert forwarded["originator"] == "codex_cli_rs"
assert forwarded["session_id"] == "sess-uuid-456"
assert "x-litellm-api-key" not in forwarded
assert "cookie" not in forwarded
assert "host" not in forwarded
assert "content-length" not in forwarded
assert result == {"id": "resp_1"}
@pytest.mark.asyncio
async def test_auth_from_cookie(self):
body = {"model": "gpt-5.3-codex", "stream": True}
mock_request = self._build_request(
headers=self._codex_headers({"cookie": "litellm_api_key=sk-cookie-key"}),
cookies={"litellm_api_key": "sk-cookie-key"},
body=body,
)
_, mock_auth, mock_create_route = await self._call_route(mock_request, body)
assert mock_auth.call_args.kwargs["api_key"] == "Bearer sk-cookie-key"
forwarded = mock_create_route.call_args[1]["custom_headers"]
assert "cookie" not in forwarded
assert forwarded["authorization"] == "Bearer chatgpt-oauth-access-token"
@pytest.mark.asyncio
async def test_header_takes_precedence_over_cookie(self):
body = {"stream": False}
mock_request = self._build_request(
headers=self._codex_headers(
{
"x-litellm-api-key": "sk-header-key",
"cookie": "litellm_api_key=sk-cookie-key",
}
),
cookies={"litellm_api_key": "sk-cookie-key"},
body=body,
)
_, mock_auth, mock_create_route = await self._call_route(mock_request, body)
assert mock_auth.call_args.kwargs["api_key"] == "Bearer sk-header-key"
assert mock_create_route.call_args[1]["is_streaming_request"] is False
@pytest.mark.asyncio
async def test_missing_litellm_credential_returns_401_without_using_siwc_bearer(self):
from fastapi import HTTPException
from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import (
chatgpt_proxy_route,
)
mock_request = self._build_request(
headers=self._codex_headers({}),
cookies={},
body={"stream": True},
)
with (
patch(
"litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.user_api_key_auth",
new_callable=AsyncMock,
) as mock_auth,
pytest.raises(HTTPException) as exc_info,
):
await chatgpt_proxy_route(
endpoint="responses",
request=mock_request,
fastapi_response=MagicMock(spec=Response),
)
assert exc_info.value.status_code == 401
mock_auth.assert_not_called()
def test_chatgpt_is_a_mapped_pass_through_route(self):
from litellm.proxy._types import LiteLLMRoutes
assert "/chatgpt" in LiteLLMRoutes.mapped_pass_through_routes.value

View file

@ -1397,6 +1397,72 @@ export interface paths {
patch?: never;
trace?: never;
};
"/chatgpt/{endpoint}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Chatgpt Proxy Route
* @description Passthrough for Codex clients signed in with ChatGPT (SIWC).
*
* The LiteLLM virtual key arrives in the `x-litellm-api-key` header or the
* `litellm_api_key` cookie and is stripped before forwarding. The
* `Authorization` header carries the ChatGPT bearer, which is forwarded
* unchanged (along with `ChatGPT-Account-ID`) to
* https://chatgpt.com/backend-api/codex.
*/
get: operations["chatgpt_proxy_route_chatgpt__endpoint__get"];
/**
* Chatgpt Proxy Route
* @description Passthrough for Codex clients signed in with ChatGPT (SIWC).
*
* The LiteLLM virtual key arrives in the `x-litellm-api-key` header or the
* `litellm_api_key` cookie and is stripped before forwarding. The
* `Authorization` header carries the ChatGPT bearer, which is forwarded
* unchanged (along with `ChatGPT-Account-ID`) to
* https://chatgpt.com/backend-api/codex.
*/
put: operations["chatgpt_proxy_route_chatgpt__endpoint__put"];
/**
* Chatgpt Proxy Route
* @description Passthrough for Codex clients signed in with ChatGPT (SIWC).
*
* The LiteLLM virtual key arrives in the `x-litellm-api-key` header or the
* `litellm_api_key` cookie and is stripped before forwarding. The
* `Authorization` header carries the ChatGPT bearer, which is forwarded
* unchanged (along with `ChatGPT-Account-ID`) to
* https://chatgpt.com/backend-api/codex.
*/
post: operations["chatgpt_proxy_route_chatgpt__endpoint__post"];
/**
* Chatgpt Proxy Route
* @description Passthrough for Codex clients signed in with ChatGPT (SIWC).
*
* The LiteLLM virtual key arrives in the `x-litellm-api-key` header or the
* `litellm_api_key` cookie and is stripped before forwarding. The
* `Authorization` header carries the ChatGPT bearer, which is forwarded
* unchanged (along with `ChatGPT-Account-ID`) to
* https://chatgpt.com/backend-api/codex.
*/
delete: operations["chatgpt_proxy_route_chatgpt__endpoint__delete"];
options?: never;
head?: never;
/**
* Chatgpt Proxy Route
* @description Passthrough for Codex clients signed in with ChatGPT (SIWC).
*
* The LiteLLM virtual key arrives in the `x-litellm-api-key` header or the
* `litellm_api_key` cookie and is stripped before forwarding. The
* `Authorization` header carries the ChatGPT bearer, which is forwarded
* unchanged (along with `ChatGPT-Account-ID`) to
* https://chatgpt.com/backend-api/codex.
*/
patch: operations["chatgpt_proxy_route_chatgpt__endpoint__patch"];
trace?: never;
};
"/claude-code/marketplace.json": {
parameters: {
query?: never;
@ -36433,6 +36499,161 @@ export interface operations {
};
};
};
chatgpt_proxy_route_chatgpt__endpoint__get: {
parameters: {
query?: never;
header?: never;
path: {
endpoint: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
chatgpt_proxy_route_chatgpt__endpoint__put: {
parameters: {
query?: never;
header?: never;
path: {
endpoint: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
chatgpt_proxy_route_chatgpt__endpoint__post: {
parameters: {
query?: never;
header?: never;
path: {
endpoint: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
chatgpt_proxy_route_chatgpt__endpoint__delete: {
parameters: {
query?: never;
header?: never;
path: {
endpoint: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
chatgpt_proxy_route_chatgpt__endpoint__patch: {
parameters: {
query?: never;
header?: never;
path: {
endpoint: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_marketplace_claude_code_marketplace_json_get: {
parameters: {
query?: never;