diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 7575091be54..47ba97bbfff 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -421,6 +421,7 @@ class LiteLLMRoutes(enum.Enum): "/bedrock", "/vertex-ai", "/vertex_ai", + "/chatgpt", "/cohere", "/cursor", "/gemini", diff --git a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py index 7e573de261b..3d45af0424f 100644 --- a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py @@ -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"], diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py index cf3351c4ff8..367bfc790e7 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py @@ -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 diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 9a33a6bd758..248dc090278 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -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;