mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
feat(proxy): add /chatgpt passthrough for Codex Sign in with ChatGPT
This commit is contained in:
parent
998a372417
commit
01cf5748d6
4 changed files with 455 additions and 0 deletions
|
|
@ -421,6 +421,7 @@ class LiteLLMRoutes(enum.Enum):
|
|||
"/bedrock",
|
||||
"/vertex-ai",
|
||||
"/vertex_ai",
|
||||
"/chatgpt",
|
||||
"/cohere",
|
||||
"/cursor",
|
||||
"/gemini",
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
221
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
221
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue