diff --git a/litellm/litellm_core_utils/get_litellm_params.py b/litellm/litellm_core_utils/get_litellm_params.py index b8ef9d8cca7..12d42228400 100644 --- a/litellm/litellm_core_utils/get_litellm_params.py +++ b/litellm/litellm_core_utils/get_litellm_params.py @@ -45,6 +45,7 @@ OPTIONAL_KWARGS_KEYS = ( "itpm", "otpm", "use_xai_oauth", + "chatgpt_auth_file", } ) | AWS_CREDENTIAL_KWARGS_KEYS diff --git a/litellm/litellm_core_utils/get_llm_provider_logic.py b/litellm/litellm_core_utils/get_llm_provider_logic.py index 487a7b7e25f..7ba2d0679b6 100644 --- a/litellm/litellm_core_utils/get_llm_provider_logic.py +++ b/litellm/litellm_core_utils/get_llm_provider_logic.py @@ -732,7 +732,9 @@ def _get_openai_compatible_provider_info( api_base, dynamic_api_key, custom_llm_provider, - ) = litellm.ChatGPTConfig()._get_openai_compatible_provider_info(model, api_base, api_key, custom_llm_provider) + ) = litellm.ChatGPTConfig()._get_openai_compatible_provider_info( + model, api_base, api_key, custom_llm_provider, litellm_params + ) elif custom_llm_provider == "novita": api_base = api_base or get_secret("NOVITA_API_BASE") or "https://api.novita.ai/v3/openai" # type: ignore dynamic_api_key = api_key or get_secret_str("NOVITA_API_KEY") diff --git a/litellm/llms/chatgpt/authenticator.py b/litellm/llms/chatgpt/authenticator.py index 277bcfa18d0..4728a5053c9 100644 --- a/litellm/llms/chatgpt/authenticator.py +++ b/litellm/llms/chatgpt/authenticator.py @@ -2,9 +2,10 @@ import base64 import json import os import time -from typing import Any, Dict, Optional +from typing import Any, Dict, Mapping, Optional, Union import httpx +from pydantic import BaseModel from litellm._logging import verbose_logger from litellm.llms.custom_httpx.http_handler import _get_httpx_client @@ -28,13 +29,28 @@ DEVICE_CODE_COOLDOWN_SECONDS = 5 * 60 DEVICE_CODE_POLL_SLEEP_SECONDS = 5 +def get_chatgpt_auth_file( + litellm_params: Union[Mapping[str, object], BaseModel, None], +) -> str | None: + if litellm_params is None: + return None + if isinstance(litellm_params, Mapping): + value = litellm_params.get("chatgpt_auth_file") + else: + value = getattr(litellm_params, "chatgpt_auth_file", None) + if isinstance(value, str) and value: + return value + return None + + class Authenticator: - def __init__(self) -> None: - self.token_dir = os.getenv( - "CHATGPT_TOKEN_DIR", - os.path.expanduser("~/.config/litellm/chatgpt"), + def __init__(self, auth_file: str | None = None) -> None: + default_auth_file = os.path.join( + os.getenv("CHATGPT_TOKEN_DIR", os.path.expanduser("~/.config/litellm/chatgpt")), + os.getenv("CHATGPT_AUTH_FILE", "auth.json"), ) - self.auth_file = os.path.join(self.token_dir, os.getenv("CHATGPT_AUTH_FILE", "auth.json")) + self.auth_file = os.path.expanduser(auth_file or default_auth_file) + self.token_dir = os.path.dirname(self.auth_file) self._ensure_token_dir() def get_api_base(self) -> str: diff --git a/litellm/llms/chatgpt/chat/transformation.py b/litellm/llms/chatgpt/chat/transformation.py index 9b0d8dc2e65..c6b9dc972de 100644 --- a/litellm/llms/chatgpt/chat/transformation.py +++ b/litellm/llms/chatgpt/chat/transformation.py @@ -1,10 +1,12 @@ -from typing import Any, List, Optional, Tuple +from typing import Any, List, Mapping, Optional, Tuple, Union + +from pydantic import BaseModel from litellm.exceptions import AuthenticationError from litellm.llms.openai.openai import OpenAIConfig from litellm.types.llms.openai import AllMessageValues -from ..authenticator import Authenticator +from ..authenticator import Authenticator, get_chatgpt_auth_file from ..common_utils import ( GetAccessTokenError, ensure_chatgpt_session_id, @@ -23,22 +25,34 @@ class ChatGPTConfig(OpenAIConfig): super().__init__() self.authenticator = Authenticator() + def _resolve_authenticator(self, litellm_params: Union[Mapping[str, object], BaseModel, None]) -> Authenticator: + auth_file = get_chatgpt_auth_file(litellm_params) + if auth_file: + return Authenticator(auth_file=auth_file) + return self.authenticator + + @staticmethod + def _get_access_token_or_raise(authenticator: Authenticator, model: str, llm_provider: str) -> str: + try: + return authenticator.get_access_token() + except GetAccessTokenError as e: + raise AuthenticationError( + model=model, + llm_provider=llm_provider, + message=str(e), + ) + def _get_openai_compatible_provider_info( self, model: str, api_base: Optional[str], api_key: Optional[str], custom_llm_provider: str, + litellm_params: Union[Mapping[str, object], BaseModel, None] = None, ) -> Tuple[Optional[str], Optional[str], str]: - dynamic_api_base = self.authenticator.get_api_base() - try: - dynamic_api_key = self.authenticator.get_access_token() - except GetAccessTokenError as e: - raise AuthenticationError( - model=model, - llm_provider=custom_llm_provider, - message=str(e), - ) + authenticator = self._resolve_authenticator(litellm_params) + dynamic_api_base = authenticator.get_api_base() + dynamic_api_key = self._get_access_token_or_raise(authenticator, model, custom_llm_provider) return dynamic_api_base, dynamic_api_key, custom_llm_provider def validate_environment( @@ -51,13 +65,20 @@ class ChatGPTConfig(OpenAIConfig): api_key: Optional[str] = None, api_base: Optional[str] = None, ) -> dict: - validated_headers = super().validate_environment( - headers, model, messages, optional_params, litellm_params, api_key, api_base + authenticator = self._resolve_authenticator(litellm_params) + resolved_api_key = ( + self._get_access_token_or_raise(authenticator, model, "chatgpt") + if get_chatgpt_auth_file(litellm_params) + else api_key ) - account_id = self.authenticator.get_account_id() + validated_headers = super().validate_environment( + headers, model, messages, optional_params, litellm_params, resolved_api_key, api_base + ) + + account_id = authenticator.get_account_id() session_id = ensure_chatgpt_session_id(litellm_params) - default_headers = get_chatgpt_default_headers(api_key or "", account_id, session_id) + default_headers = get_chatgpt_default_headers(resolved_api_key or "", account_id, session_id) return {**default_headers, **validated_headers} def post_stream_processing(self, stream: Any) -> Any: diff --git a/litellm/llms/chatgpt/responses/transformation.py b/litellm/llms/chatgpt/responses/transformation.py index 8b5fae4ef35..ea81a3c1e14 100644 --- a/litellm/llms/chatgpt/responses/transformation.py +++ b/litellm/llms/chatgpt/responses/transformation.py @@ -19,7 +19,7 @@ from litellm.types.llms.openai import ( from litellm.types.router import GenericLiteLLMParams from litellm.types.utils import LlmProviders -from ..authenticator import Authenticator +from ..authenticator import Authenticator, get_chatgpt_auth_file from ..common_utils import ( CHATGPT_API_BASE, GetAccessTokenError, @@ -38,14 +38,21 @@ class ChatGPTResponsesAPIConfig(OpenAIResponsesAPIConfig): def custom_llm_provider(self) -> LlmProviders: return LlmProviders.CHATGPT + def _resolve_authenticator(self, litellm_params: GenericLiteLLMParams | None) -> Authenticator: + auth_file = get_chatgpt_auth_file(litellm_params) + if auth_file: + return Authenticator(auth_file=auth_file) + return self.authenticator + def validate_environment( self, headers: dict, model: str, litellm_params: Optional[GenericLiteLLMParams], ) -> dict: + authenticator = self._resolve_authenticator(litellm_params) try: - access_token = self.authenticator.get_access_token() + access_token = authenticator.get_access_token() except GetAccessTokenError as e: raise AuthenticationError( model=model, @@ -53,7 +60,7 @@ class ChatGPTResponsesAPIConfig(OpenAIResponsesAPIConfig): message=str(e), ) - account_id = self.authenticator.get_account_id() + account_id = authenticator.get_account_id() session_id = ensure_chatgpt_session_id(litellm_params) default_headers = get_chatgpt_default_headers(access_token, account_id, session_id) return {**default_headers, **headers} diff --git a/litellm/main.py b/litellm/main.py index 6fd68921fb0..d8b187767db 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -5325,6 +5325,7 @@ def completion( # type: ignore tpm=kwargs.get("tpm"), rpm=kwargs.get("rpm"), use_xai_oauth=kwargs.get("use_xai_oauth", False), + chatgpt_auth_file=kwargs.get("chatgpt_auth_file"), **{key: kwargs[key] for key in AWS_CREDENTIAL_KWARGS_KEYS if key in kwargs}, ) cast(LiteLLMLoggingObj, logging).update_environment_variables( diff --git a/litellm/types/router.py b/litellm/types/router.py index 69a8ca9f19e..b833a3a1d75 100644 --- a/litellm/types/router.py +++ b/litellm/types/router.py @@ -246,6 +246,10 @@ class GenericLiteLLMParams(CredentialLiteLLMParams, CustomPricingLiteLLMParams): default=False, description="Use stored xAI OAuth credentials when no xAI API key is configured.", ) + chatgpt_auth_file: Optional[str] = Field( + default=None, + description="Path to a ChatGPT OAuth auth.json for this deployment; lets one instance run multiple ChatGPT accounts.", + ) model_config = ConfigDict(extra="allow", arbitrary_types_allowed=True) merge_reasoning_content_in_choices: Optional[bool] = False model_info: Optional[Dict] = None diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 04f1ff68c5d..321c8901ab7 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -3235,6 +3235,7 @@ all_litellm_params = ( "enable_tag_filtering", "enable_json_schema_validation", "use_xai_oauth", + "chatgpt_auth_file", "_litellm_rate_limit_descriptors", "_litellm_tpm_reserved_tokens", "_litellm_tpm_reserved_model", diff --git a/tests/test_litellm/llms/chatgpt/chat/test_chatgpt_chat_transformation.py b/tests/test_litellm/llms/chatgpt/chat/test_chatgpt_chat_transformation.py new file mode 100644 index 00000000000..4157050fdb0 --- /dev/null +++ b/tests/test_litellm/llms/chatgpt/chat/test_chatgpt_chat_transformation.py @@ -0,0 +1,107 @@ +""" +Tests for ChatGPT subscription chat transformation + +Source: litellm/llms/chatgpt/chat/transformation.py +""" + +import json +import time + +import pytest + +from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider +from litellm.llms.chatgpt.chat.transformation import ChatGPTConfig +from litellm.types.router import GenericLiteLLMParams + + +def _write_auth_record(path, token: str, account_id: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps( + { + "access_token": token, + "account_id": account_id, + "expires_at": time.time() + 3600, + } + ) + ) + + +@pytest.fixture +def auth_files(tmp_path, monkeypatch): + default_dir = tmp_path / "default" + monkeypatch.setenv("CHATGPT_TOKEN_DIR", str(default_dir)) + monkeypatch.delenv("CHATGPT_AUTH_FILE", raising=False) + _write_auth_record(default_dir / "auth.json", "token-default", "acct-default") + file_a = tmp_path / "account-a" / "auth.json" + file_b = tmp_path / "account-b" / "auth.json" + _write_auth_record(file_a, "token-a", "acct-a") + _write_auth_record(file_b, "token-b", "acct-b") + return file_a, file_b + + +class TestChatGPTMultiAccountChat: + def test_validate_environment_uses_per_model_auth_file(self, auth_files): + file_a, file_b = auth_files + config = ChatGPTConfig() + + headers_a = config.validate_environment( + headers={}, + model="gpt-5.4", + messages=[], + optional_params={}, + litellm_params={"chatgpt_auth_file": str(file_a)}, + api_key="token-default", + ) + headers_b = config.validate_environment( + headers={}, + model="gpt-5.4", + messages=[], + optional_params={}, + litellm_params={"chatgpt_auth_file": str(file_b)}, + api_key="token-default", + ) + + assert headers_a["Authorization"] == "Bearer token-a" + assert headers_a["ChatGPT-Account-Id"] == "acct-a" + assert headers_b["Authorization"] == "Bearer token-b" + assert headers_b["ChatGPT-Account-Id"] == "acct-b" + + def test_validate_environment_defaults_to_global_auth_file(self, auth_files): + config = ChatGPTConfig() + + headers = config.validate_environment( + headers={}, + model="gpt-5.4", + messages=[], + optional_params={}, + litellm_params={}, + api_key="token-default", + ) + + assert headers["Authorization"] == "Bearer token-default" + assert headers["ChatGPT-Account-Id"] == "acct-default" + + def test_provider_info_uses_per_model_auth_file(self, auth_files): + file_a, _ = auth_files + config = ChatGPTConfig() + + _, per_model_key, _ = config._get_openai_compatible_provider_info( + "gpt-5.4", None, None, "chatgpt", {"chatgpt_auth_file": str(file_a)} + ) + _, default_key, _ = config._get_openai_compatible_provider_info("gpt-5.4", None, None, "chatgpt") + + assert per_model_key == "token-a" + assert default_key == "token-default" + + def test_get_llm_provider_threads_auth_file(self, auth_files): + file_a, _ = auth_files + + model, provider, dynamic_api_key, _ = get_llm_provider( + model="chatgpt/gpt-5.4", + litellm_params=GenericLiteLLMParams(chatgpt_auth_file=str(file_a)), + ) + + assert model == "gpt-5.4" + assert provider == "chatgpt" + assert dynamic_api_key == "token-a" diff --git a/tests/test_litellm/llms/chatgpt/responses/test_chatgpt_responses_transformation.py b/tests/test_litellm/llms/chatgpt/responses/test_chatgpt_responses_transformation.py index 90a1c24bada..d744e40b84b 100644 --- a/tests/test_litellm/llms/chatgpt/responses/test_chatgpt_responses_transformation.py +++ b/tests/test_litellm/llms/chatgpt/responses/test_chatgpt_responses_transformation.py @@ -7,6 +7,7 @@ Source: litellm/llms/chatgpt/responses/transformation.py import json import os import sys +import time from unittest.mock import MagicMock, patch import httpx @@ -326,3 +327,53 @@ class TestChatGPTResponsesAPITransformation: assert "ChatGPT upstream failed" in str(exc_info.value) assert exc_info.value.status_code == 502 + + +class TestChatGPTResponsesMultiAccount: + @staticmethod + def _write_auth_record(path, token: str, account_id: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps( + { + "access_token": token, + "account_id": account_id, + "expires_at": time.time() + 3600, + } + ) + ) + + def test_validate_environment_uses_per_model_auth_file(self, tmp_path, monkeypatch): + default_dir = tmp_path / "default" + monkeypatch.setenv("CHATGPT_TOKEN_DIR", str(default_dir)) + monkeypatch.delenv("CHATGPT_AUTH_FILE", raising=False) + self._write_auth_record(default_dir / "auth.json", "token-default", "acct-default") + file_a = tmp_path / "account-a" / "auth.json" + file_b = tmp_path / "account-b" / "auth.json" + self._write_auth_record(file_a, "token-a", "acct-a") + self._write_auth_record(file_b, "token-b", "acct-b") + + config = ChatGPTResponsesAPIConfig() + + headers_a = config.validate_environment( + headers={}, + model="gpt-5.4", + litellm_params=GenericLiteLLMParams(chatgpt_auth_file=str(file_a)), + ) + headers_b = config.validate_environment( + headers={}, + model="gpt-5.4", + litellm_params=GenericLiteLLMParams(chatgpt_auth_file=str(file_b)), + ) + headers_default = config.validate_environment( + headers={}, + model="gpt-5.4", + litellm_params=GenericLiteLLMParams(), + ) + + assert headers_a["Authorization"] == "Bearer token-a" + assert headers_a["ChatGPT-Account-Id"] == "acct-a" + assert headers_b["Authorization"] == "Bearer token-b" + assert headers_b["ChatGPT-Account-Id"] == "acct-b" + assert headers_default["Authorization"] == "Bearer token-default" + assert headers_default["ChatGPT-Account-Id"] == "acct-default" diff --git a/tests/test_litellm/llms/chatgpt/test_chatgpt_authenticator.py b/tests/test_litellm/llms/chatgpt/test_chatgpt_authenticator.py index a9ced2afcf9..81079bb8eb5 100644 --- a/tests/test_litellm/llms/chatgpt/test_chatgpt_authenticator.py +++ b/tests/test_litellm/llms/chatgpt/test_chatgpt_authenticator.py @@ -5,7 +5,8 @@ from unittest.mock import mock_open, patch import pytest -from litellm.llms.chatgpt.authenticator import Authenticator +from litellm.llms.chatgpt.authenticator import Authenticator, get_chatgpt_auth_file +from litellm.types.router import GenericLiteLLMParams def _make_jwt(payload: dict) -> str: @@ -68,3 +69,58 @@ class TestChatGPTAuthenticator: assert account_id == "acct-123" mock_write.assert_called_once() assert mock_write.call_args[0][0]["account_id"] == "acct-123" + + +def _write_auth_record(path, token: str, account_id: str) -> None: + path.write_text( + json.dumps( + { + "access_token": token, + "account_id": account_id, + "expires_at": time.time() + 3600, + } + ) + ) + + +class TestChatGPTMultiAccountAuthenticator: + def test_explicit_auth_file_overrides_env(self, tmp_path, monkeypatch): + monkeypatch.setenv("CHATGPT_TOKEN_DIR", str(tmp_path / "env-dir")) + monkeypatch.setenv("CHATGPT_AUTH_FILE", "env.json") + custom_file = tmp_path / "account-a" / "auth.json" + + authenticator = Authenticator(auth_file=str(custom_file)) + + assert authenticator.auth_file == str(custom_file) + assert authenticator.token_dir == str(custom_file.parent) + assert custom_file.parent.exists() + + def test_default_auth_file_from_env(self, tmp_path, monkeypatch): + monkeypatch.setenv("CHATGPT_TOKEN_DIR", str(tmp_path / "env-dir")) + monkeypatch.setenv("CHATGPT_AUTH_FILE", "env.json") + + authenticator = Authenticator() + + assert authenticator.auth_file == str(tmp_path / "env-dir" / "env.json") + + def test_two_auth_files_are_isolated(self, tmp_path): + file_a = tmp_path / "account-a.json" + file_b = tmp_path / "account-b.json" + _write_auth_record(file_a, "token-a", "acct-a") + _write_auth_record(file_b, "token-b", "acct-b") + + authenticator_a = Authenticator(auth_file=str(file_a)) + authenticator_b = Authenticator(auth_file=str(file_b)) + + assert authenticator_a.get_access_token() == "token-a" + assert authenticator_a.get_account_id() == "acct-a" + assert authenticator_b.get_access_token() == "token-b" + assert authenticator_b.get_account_id() == "acct-b" + + def test_get_chatgpt_auth_file(self): + assert get_chatgpt_auth_file(None) is None + assert get_chatgpt_auth_file({}) is None + assert get_chatgpt_auth_file({"chatgpt_auth_file": ""}) is None + assert get_chatgpt_auth_file({"chatgpt_auth_file": "/a/auth.json"}) == "/a/auth.json" + assert get_chatgpt_auth_file(GenericLiteLLMParams()) is None + assert get_chatgpt_auth_file(GenericLiteLLMParams(chatgpt_auth_file="/b/auth.json")) == "/b/auth.json" diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 9ad0b8d1101..351de6be1c9 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -25646,6 +25646,11 @@ export interface components { cache_read_input_token_cost_flex?: number | null; /** Cache Read Input Token Cost Priority */ cache_read_input_token_cost_priority?: number | null; + /** + * Chatgpt Auth File + * @description Path to a ChatGPT OAuth auth.json for this deployment; lets one instance run multiple ChatGPT accounts. + */ + chatgpt_auth_file?: string | null; /** Citation Cost Per Token */ citation_cost_per_token?: number | null; /** Complexity Router Config */ @@ -33482,6 +33487,11 @@ export interface components { cache_read_input_token_cost_flex?: number | null; /** Cache Read Input Token Cost Priority */ cache_read_input_token_cost_priority?: number | null; + /** + * Chatgpt Auth File + * @description Path to a ChatGPT OAuth auth.json for this deployment; lets one instance run multiple ChatGPT accounts. + */ + chatgpt_auth_file?: string | null; /** Citation Cost Per Token */ citation_cost_per_token?: number | null; /** Complexity Router Config */