mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-26 06:03:19 +00:00
feat(chatgpt): support per-model chatgpt_auth_file for multiple ChatGPT accounts
This commit is contained in:
parent
4d33964898
commit
091847bd6e
12 changed files with 303 additions and 26 deletions
|
|
@ -45,6 +45,7 @@ OPTIONAL_KWARGS_KEYS = (
|
|||
"itpm",
|
||||
"otpm",
|
||||
"use_xai_oauth",
|
||||
"chatgpt_auth_file",
|
||||
}
|
||||
)
|
||||
| AWS_CREDENTIAL_KWARGS_KEYS
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
10
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
10
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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 */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue