feat(chatgpt): support per-model chatgpt_auth_file for multiple ChatGPT accounts

This commit is contained in:
Urbanonymous 2026-07-17 12:23:04 +02:00
parent 4d33964898
commit 091847bd6e
12 changed files with 303 additions and 26 deletions

View file

@ -45,6 +45,7 @@ OPTIONAL_KWARGS_KEYS = (
"itpm",
"otpm",
"use_xai_oauth",
"chatgpt_auth_file",
}
)
| AWS_CREDENTIAL_KWARGS_KEYS

View file

@ -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")

View file

@ -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:

View file

@ -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:

View file

@ -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}

View file

@ -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(

View file

@ -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

View file

@ -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",

View file

@ -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"

View file

@ -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"

View file

@ -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"

View file

@ -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 */