feat(chatgpt): bind a ChatGPT subscription login to one team or key

A chatgpt/ deployment whose litellm_credential_name names a subscription login now runs on the access token the caller's own team or key owns, and answers 401 for any other caller instead of falling back to the host's auth file. A request body cannot override that credential name, the login values never merge into call kwargs, and their keys are server owned so a body or non-admin credential write cannot set them. acompletion also passes the call's api_key into its first provider lookup, so a bound login no longer triggers the host device-code login
This commit is contained in:
mateo-berri 2026-10-29 10:01:50 -07:00
parent b25ea48c4b
commit 2132c83dd1
14 changed files with 1116 additions and 6 deletions

View file

@ -0,0 +1,155 @@
from collections.abc import Callable, Mapping
from types import MappingProxyType
from typing import Final
from pydantic import TypeAdapter, ValidationError
from litellm.exceptions import CallerCredentialAuthenticationError
from litellm.litellm_core_utils.credential_accessor import CredentialAccessor
from litellm.llms.chatgpt.common_utils import CHATGPT_ACCOUNT_ID_HEADER, CHATGPT_API_BASE, chatgpt_client_api_base
from litellm.types.utils import CredentialItem, chatgpt_subscription_login_litellm_params
CHATGPT_SUBSCRIPTION_LOGINS_SECRET_FIELD: Final = "chatgpt_subscription_logins"
CHATGPT_OWNER_TEAM_ID_KEY: Final = "chatgpt_owner_team_id"
CHATGPT_OWNER_KEY_HASH_KEY: Final = "chatgpt_owner_key_hash"
CHATGPT_PROVIDER: Final = "chatgpt"
_LOGIN_HEADER_NAMES: Final = frozenset({"authorization", CHATGPT_ACCOUNT_ID_HEADER.lower()})
_STR_KEYED: Final = TypeAdapter(Mapping[str, object])
_ENTRIES: Final = TypeAdapter(tuple[object, ...])
def is_chatgpt_subscription_login(credential: CredentialItem) -> bool:
return any(key in credential.credential_values for key in chatgpt_subscription_login_litellm_params)
def names_chatgpt_subscription_login(credential_name: object) -> bool:
if not isinstance(credential_name, str) or not credential_name:
return False
credential: Final = CredentialAccessor.find_credential(credential_name)
return credential is not None and is_chatgpt_subscription_login(credential)
def locked_chatgpt_credential_name(deployment_params: object) -> str | None:
params: Final = _str_keyed(deployment_params)
credential_name: Final = params.get("litellm_credential_name") if params is not None else None
if params is None or not isinstance(credential_name, str) or not credential_name:
return None
if names_chatgpt_subscription_login(credential_name) or _is_chatgpt_call(params, params.get("model")):
return credential_name
return None
def _str_keyed(value: object) -> Mapping[str, object] | None:
if not isinstance(value, Mapping):
return None
try:
return _STR_KEYED.validate_python(value)
except ValidationError:
return None
def _is_chatgpt_call(kwargs: Mapping[str, object], model: object) -> bool:
return kwargs.get("custom_llm_provider") == CHATGPT_PROVIDER or any(
isinstance(candidate, str) and candidate.startswith(f"{CHATGPT_PROVIDER}/")
for candidate in (model, kwargs.get("model"))
)
def _login_unavailable(credential_name: str) -> CallerCredentialAuthenticationError:
return CallerCredentialAuthenticationError(
message=(
f"ChatGPT subscription login '{credential_name}' is not available to this key. "
"An admin can sign in to ChatGPT for this team or key under LLM Credentials in the LiteLLM UI"
),
llm_provider=CHATGPT_PROVIDER,
model="",
)
def _owned_access_token(kwargs: Mapping[str, object], credential_name: str) -> str | None:
secret_fields: Final = _str_keyed(kwargs.get("secret_fields"))
logins: Final = _str_keyed(
secret_fields.get(CHATGPT_SUBSCRIPTION_LOGINS_SECRET_FIELD) if secret_fields is not None else None
)
token: Final = logins.get(credential_name) if logins is not None else None
return token if isinstance(token, str) and token else None
def _without_login_headers(headers: Mapping[str, object]) -> Mapping[str, object]:
return {key: value for key, value in headers.items() if key.lower() not in _LOGIN_HEADER_NAMES}
def _scoped_header_without_login(entry: object) -> object:
scoped: Final = _str_keyed(entry)
extra_headers: Final = _str_keyed(scoped.get("extra_headers")) if scoped is not None else None
if scoped is None or extra_headers is None:
return entry
return {**scoped, "extra_headers": _without_login_headers(extra_headers)}
def _provider_specific_header_without_login(value: object) -> object:
if isinstance(value, (list, tuple)):
return [_scoped_header_without_login(entry) for entry in _ENTRIES.validate_python(value)]
return _scoped_header_without_login(value)
def _nested_extra_headers_without_login(params: object) -> object:
mapping: Final = _str_keyed(params)
extra_headers: Final = _str_keyed(mapping.get("extra_headers")) if mapping is not None else None
if mapping is None or extra_headers is None:
return params
return {**mapping, "extra_headers": _without_login_headers(extra_headers)}
def _header_mapping_without_login(value: object) -> object:
headers: Final = _str_keyed(value)
return value if headers is None else _without_login_headers(headers)
def _subscription_api_base(kwargs: Mapping[str, object]) -> str:
validated: Final = tuple(
chatgpt_client_api_base(value)
for value in (kwargs.get("base_url"), kwargs.get("api_base"))
if isinstance(value, str) and value
)
return validated[0] if validated else CHATGPT_API_BASE
_HEADER_CARRIERS: Final[tuple[tuple[str, Callable[[object], object]], ...]] = (
("provider_specific_header", _provider_specific_header_without_login),
("extra_headers", _header_mapping_without_login),
("headers", _header_mapping_without_login),
("optional_params", _nested_extra_headers_without_login),
("litellm_params", _nested_extra_headers_without_login),
)
def _request_updates(kwargs: Mapping[str, object], access_token: str) -> Mapping[str, object]:
return MappingProxyType(
{
**{key: rebuild(kwargs[key]) for key, rebuild in _HEADER_CARRIERS if key in kwargs},
"api_key": access_token,
"api_base": _subscription_api_base(kwargs),
}
)
def attach_chatgpt_subscription_login(
kwargs: dict[str, object], # mutable-ok: kwargs is the request mutation channel
model: object,
) -> None:
credential_name: Final = kwargs.get("litellm_credential_name")
if not isinstance(credential_name, str) or not credential_name:
return
credential: Final = CredentialAccessor.find_credential(credential_name)
if credential is None and not _is_chatgpt_call(kwargs, model):
return
if credential is not None and not is_chatgpt_subscription_login(credential):
return
access_token: Final = _owned_access_token(kwargs, credential_name)
if credential is None or access_token is None or not _is_chatgpt_call(kwargs, model):
raise _login_unavailable(credential_name)
updates: Final = _request_updates(kwargs, access_token)
kwargs.pop("chatgpt_auth_file", None)
kwargs.pop("base_url", None)
kwargs.update(updates)

View file

@ -698,6 +698,7 @@ async def acompletion(
"str | None", kwargs.get("api_base")
)
or base_url,
api_key=api_key,
litellm_params=(GenericLiteLLMParams.model_validate(_supplemental) if _supplemental else None),
)

View file

@ -469,6 +469,7 @@ from litellm.types.utils import (
StandardLoggingRoutingDecision,
Usage,
all_litellm_params,
chatgpt_subscription_login_litellm_params,
is_server_derived_pricing_key,
shared_backend_model_info,
)
@ -5075,6 +5076,22 @@ class Router:
model=deployment_model_name,
llm_provider="",
)
from litellm.llms.chatgpt.subscription_login import locked_chatgpt_credential_name
locked_chatgpt_name: Final = locked_chatgpt_credential_name(deployment_context)
if (
locked_chatgpt_name is not None
and "litellm_credential_name" in kwargs
and caller_credential_name != locked_chatgpt_name
):
raise litellm.BadRequestError(
message=(
"litellm_credential_name cannot be overridden on a deployment that uses "
"a ChatGPT subscription login"
),
model=declared_model if isinstance(declared_model, str) else "",
llm_provider="",
)
kwargs["model_info"] = model_info
@ -12377,7 +12394,11 @@ class Router:
verbose_router_logger.warning(
"Credential '%s' not found in credential_list", deployment.litellm_params.litellm_credential_name
)
credentials.update(credential_values)
credentials.update(
(key, value)
for key, value in credential_values.items()
if key not in chatgpt_subscription_login_litellm_params
)
# Remove the credential name since we've resolved it
credentials.pop("litellm_credential_name", None)

View file

@ -115,6 +115,7 @@ def get_dynamic_litellm_params(litellm_params: dict, request_kwargs: dict) -> di
# admin's value in ``litellm_params`` and have it forwarded to the
# redirected upstream.
if "api_base" in request_kwargs or "base_url" in request_kwargs:
from litellm.llms.chatgpt.subscription_login import names_chatgpt_subscription_login
from litellm.llms.github_copilot.per_user_auth import github_copilot_auth_mode
if sends_api_key_secret_reference(litellm_params): # pyright: ignore[reportUnknownArgumentType] # litellm_params is the untyped request dict
@ -141,6 +142,8 @@ def get_dynamic_litellm_params(litellm_params: dict, request_kwargs: dict) -> di
per_user_credential_locked: Final = github_copilot_auth_mode(
credential_name_value, # pyright: ignore[reportUnknownArgumentType] # value comes from the untyped request dict
auth_type, # pyright: ignore[reportUnknownArgumentType] # value comes from the untyped request dict
) or names_chatgpt_subscription_login(
credential_name_value # pyright: ignore[reportUnknownArgumentType] # value comes from the untyped request dict
)
for field in _ADMIN_CONFIG_FIELDS_TO_CLEAR_ON_BASE_OVERRIDE:
if per_user_credential_locked and field in ("litellm_credential_name", "github_copilot_auth_type"):

View file

@ -26,3 +26,4 @@ class SecretFields(TypedDict):
raw_headers: dict
user_provider_credentials: NotRequired[ReadOnly[Mapping[str, Mapping[str, str]]]]
user_provider_credentials_user_id: NotRequired[ReadOnly[str]]
chatgpt_subscription_logins: NotRequired[ReadOnly[Mapping[str, str]]]

View file

@ -4267,6 +4267,15 @@ oauth_token_exchange_litellm_params: Final = (
"token_exchange_scope",
)
github_copilot_oauth_litellm_params: Final = ("github_copilot_auth_type", "github_copilot_host")
chatgpt_subscription_login_secret_params: Final = (
"chatgpt_access_token",
"chatgpt_refresh_token",
"chatgpt_id_token",
)
chatgpt_subscription_login_litellm_params: Final = chatgpt_subscription_login_secret_params + (
"chatgpt_account_id",
"chatgpt_expires_at",
)
OIDC_SECRET_REFERENCE_PREFIX: Final = "oidc/"
API_KEY_SECRET_REFERENCE_REJECTION: Final = (
"Rejected Request: api_key holds an oidc/ secret reference, which only a deployment or credential a proxy "
@ -4376,8 +4385,11 @@ server_owned_wif_litellm_params: Final = (
+ oauth_token_exchange_litellm_params
+ github_copilot_oauth_litellm_params
+ ("chatgpt_auth_file",)
+ chatgpt_subscription_login_litellm_params
)
secret_bearing_wif_litellm_params: Final = tuple(
sorted(WIF_SECRET_BEARING_KEYS.union(chatgpt_subscription_login_secret_params))
)
secret_bearing_wif_litellm_params: Final = tuple(sorted(WIF_SECRET_BEARING_KEYS))
all_litellm_params = [ # rebind-ok: two star imports in litellm/__init__.py re-bind it
*OWNED_KWARG_NAMES,

View file

@ -267,6 +267,7 @@ from litellm.types.utils import (
StreamingChoices,
TranscriptionResponse,
Usage,
chatgpt_subscription_login_litellm_params,
credential_api_key_secret_reference,
is_litellm_owned_kwarg,
)
@ -779,7 +780,7 @@ def _named_credential_values(kwargs: Mapping[str, object]) -> Mapping[str, objec
{
key: value
for key, value in credential_api_key_secret_reference(credential.credential_values).items()
if key not in kwargs
if key not in kwargs and key not in chatgpt_subscription_login_litellm_params
}
)
@ -1940,6 +1941,9 @@ def client(original_function):
from litellm.llms.github_copilot.per_user_auth import attach_github_copilot_user_session
attach_github_copilot_user_session(kwargs) # pyright: ignore[reportUnknownArgumentType] # kwargs is the untyped request dict
from litellm.llms.chatgpt.subscription_login import attach_chatgpt_subscription_login
attach_chatgpt_subscription_login(kwargs, model) # pyright: ignore[reportUnknownArgumentType] # kwargs is the untyped request dict
kwargs["litellm_logging_obj"] = logging_obj
LLMCachingHandler: Final = _get_cached_llm_caching_handler()
_llm_caching_handler: Final[LLMCachingHandler] = LLMCachingHandler(
@ -2234,6 +2238,9 @@ def client(original_function):
from litellm.llms.github_copilot.per_user_auth import aattach_github_copilot_user_session
await aattach_github_copilot_user_session(kwargs) # pyright: ignore[reportUnknownArgumentType] # kwargs is the untyped request dict
from litellm.llms.chatgpt.subscription_login import attach_chatgpt_subscription_login
attach_chatgpt_subscription_login(kwargs, model) # pyright: ignore[reportUnknownArgumentType] # kwargs is the untyped request dict
logging_obj.llm_caching_handler = _llm_caching_handler
# [OPTIONAL] CHECK BUDGET
if litellm.max_budget:

View file

@ -4,7 +4,7 @@ import pytest
from litellm.exceptions import AuthenticationError
from litellm.llms.chatgpt.chat.transformation import ChatGPTConfig
from litellm.llms.chatgpt.common_utils import GetAccessTokenError
from litellm.llms.chatgpt.common_utils import CHATGPT_API_BASE, GetAccessTokenError
GATEWAY_BASE = "https://chatgpt.example/backend-api/codex"
@ -72,7 +72,9 @@ import litellm
from litellm.integrations.dotprompt.dotprompt_manager import DotpromptManager
from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
from litellm.llms.chatgpt.chat.transformation import ChatGPTConfig
from litellm.types.proxy.litellm_pre_call_utils import RedactedDict
from litellm.types.router import GenericLiteLLMParams
from litellm.types.utils import CredentialItem
def _write_auth_record(path: Path, token: str, account_id: str) -> None:
@ -636,3 +638,54 @@ async def test_requests_preserve_gateway_token_and_selected_workspace(
else await litellm.aresponses(**params, input=[{"role": "user", "content": "Hello"}])
)
assert response.output[0].content[0].text == expected_account
@pytest.mark.parametrize("sync", [False, True])
@pytest.mark.parametrize("through_router", [False, True])
@pytest.mark.asyncio
async def test_chat_on_a_subscription_deployment_sends_the_owned_login_instead_of_the_host_auth_file(
auth_files: tuple[Path, Path], through_router: bool, sync: bool, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
subscription_token: Final = _client_token("acct-team-a")
login: Final = CredentialItem(
credential_name="team-a-chatgpt",
credential_values={
"chatgpt_access_token": subscription_token,
"chatgpt_refresh_token": "refresh-team-a-example",
"chatgpt_id_token": "id-team-a-example",
"chatgpt_account_id": "acct-team-a",
"chatgpt_expires_at": "4102444800",
},
credential_info={"custom_llm_provider": "chatgpt", "chatgpt_owner_team_id": "team-a"},
)
monkeypatch.setattr(litellm, "credential_list", [login])
deployment_params: Final = {"model": "chatgpt/gpt-6.1-sol", "litellm_credential_name": login.credential_name}
router: Final = (
litellm.Router(model_list=[{"model_name": "team-a-gpt", "litellm_params": deployment_params}])
if through_router
else None
)
params: Final = {
**({"model": "team-a-gpt"} if router else deployment_params),
"messages": [{"role": "user", "content": "Hello"}],
"secret_fields": {
"raw_headers": {},
"chatgpt_subscription_logins": RedactedDict({login.credential_name: subscription_token}),
},
}
complete: Final = (router.completion if sync else router.acompletion) if router else (
litellm.completion if sync else litellm.acompletion
)
with respx.mock as transport:
route: Final = transport.post(f"{CHATGPT_API_BASE}/responses").mock(side_effect=_subscription_response)
result: Final = await asyncio.to_thread(complete, **params) if sync else await complete(**params)
assert result.choices[0].message.content == "acct-team-a"
assert route.call_count == 1
sent: Final = route.calls.last.request
assert sent.headers.get_list("authorization") == [f"Bearer {subscription_token}"]
body: Final = sent.content.decode()
assert [value for value in login.credential_values.values() if value in body] == []
assert [key for key in login.credential_values if key in body] == []

View file

@ -4,6 +4,7 @@ Tests for ChatGPT subscription Responses API transformation
Source: litellm/llms/chatgpt/responses/transformation.py
"""
import asyncio
import base64
import json
from collections.abc import Generator
@ -13,15 +14,18 @@ from unittest.mock import MagicMock, patch
import httpx
import pytest
import respx
import litellm
from litellm.llms.chatgpt.authenticator import Authenticator
from litellm.llms.custom_httpx.http_handler import HTTPHandler
from litellm.llms.chatgpt.common_utils import CHATGPT_API_BASE
from litellm.llms.chatgpt.responses.transformation import ChatGPTResponsesAPIConfig
from litellm.llms.custom_httpx.http_handler import HTTPHandler
from litellm.llms.openai.common_utils import OpenAIError
from litellm.main import responses_api_bridge_check
from litellm.types.proxy.litellm_pre_call_utils import RedactedDict
from litellm.types.router import GenericLiteLLMParams
from litellm.types.utils import LlmProviders
from litellm.types.utils import CredentialItem, LlmProviders
from litellm.utils import ProviderConfigManager
@ -667,3 +671,127 @@ def test_prompt_cache_key_keeps_one_session_header_across_requests(
)
assert [call.args[0].headers["session_id"] for call in upstream.call_args_list] == ["conv-123", "conv-123"]
def _team_subscription_login(access_token: str) -> CredentialItem:
return CredentialItem(
credential_name="team-a-chatgpt",
credential_values={
"chatgpt_access_token": access_token,
"chatgpt_refresh_token": "refresh-team-a-example",
"chatgpt_id_token": "id-team-a-example",
"chatgpt_account_id": "acct-team-a",
"chatgpt_expires_at": "4102444800",
},
credential_info={"custom_llm_provider": "chatgpt", "chatgpt_owner_team_id": "team-a"},
)
def _completed_responses_reply(_request: httpx.Request) -> httpx.Response:
completed: Final = {
"type": "response.completed",
"response": {
"id": "resp_1",
"object": "response",
"created_at": 1,
"status": "completed",
"model": "gpt-5.5",
"output": [
{
"type": "message",
"id": "msg_1",
"status": "completed",
"role": "assistant",
"content": [{"type": "output_text", "text": "OK", "annotations": []}],
}
],
"usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2},
},
}
return httpx.Response(
200,
headers={"content-type": "text/event-stream"},
content=f"event: response.completed\ndata: {json.dumps(completed)}\n\n".encode(),
)
@pytest.mark.parametrize("sync", [False, True])
@pytest.mark.asyncio
async def test_a_subscription_login_replaces_the_host_auth_file_login_on_the_wire(
sync: bool, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
monkeypatch.setenv("CHATGPT_TOKEN_DIR", str(tmp_path))
monkeypatch.delenv("CHATGPT_AUTH_FILE", raising=False)
host_token: Final = _fake_chatgpt_oauth_token("acct-host")
(tmp_path / "auth.json").write_text(
json.dumps({"access_token": host_token, "account_id": "acct-host", "expires_at": 4102444800})
)
subscription_token: Final = _fake_chatgpt_oauth_token("acct-team-a")
login: Final = _team_subscription_login(subscription_token)
monkeypatch.setattr(litellm, "credential_list", [login])
params: Final = {
"model": "chatgpt/gpt-5.5",
"input": "hi",
"litellm_credential_name": login.credential_name,
"secret_fields": {
"raw_headers": {},
"chatgpt_subscription_logins": RedactedDict({login.credential_name: subscription_token}),
},
}
with respx.mock(assert_all_called=True) as upstream:
route: Final = upstream.post(f"{CHATGPT_API_BASE}/responses").mock(side_effect=_completed_responses_reply)
response: Final = (
await asyncio.to_thread(litellm.responses, **params) if sync else await litellm.aresponses(**params)
)
assert response.output_text == "OK"
assert route.call_count == 1
sent: Final = route.calls.last.request
assert sent.headers.get_list("authorization") == [f"Bearer {subscription_token}"]
assert sent.headers.get_list("chatgpt-account-id") == ["acct-team-a"]
body: Final = sent.content.decode()
assert [value for value in login.credential_values.values() if str(value) in body] == []
assert [key for key in login.credential_values if key in body] == []
assert host_token not in body
@pytest.mark.parametrize("sync", [False, True])
@pytest.mark.parametrize("owned_logins", [None, {"team-b-chatgpt": "team-b-example-token"}])
@pytest.mark.asyncio
async def test_a_subscription_login_the_caller_does_not_own_never_falls_back_to_the_host_auth_file(
sync: bool, owned_logins: dict[str, str] | None, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
monkeypatch.setenv("CHATGPT_TOKEN_DIR", str(tmp_path))
monkeypatch.delenv("CHATGPT_AUTH_FILE", raising=False)
(tmp_path / "auth.json").write_text(
json.dumps(
{
"access_token": _fake_chatgpt_oauth_token("acct-host"),
"account_id": "acct-host",
"expires_at": 4102444800,
}
)
)
login: Final = _team_subscription_login(_fake_chatgpt_oauth_token("acct-team-a"))
monkeypatch.setattr(litellm, "credential_list", [login])
params: Final = {
"model": "chatgpt/gpt-5.5",
"input": "hi",
"litellm_credential_name": login.credential_name,
**(
{}
if owned_logins is None
else {"secret_fields": {"raw_headers": {}, "chatgpt_subscription_logins": RedactedDict(owned_logins)}}
),
}
with respx.mock(assert_all_called=False) as upstream:
route: Final = upstream.post(f"{CHATGPT_API_BASE}/responses").mock(side_effect=_completed_responses_reply)
with pytest.raises(litellm.CallerCredentialAuthenticationError) as raised:
await (asyncio.to_thread(litellm.responses, **params) if sync else litellm.aresponses(**params))
assert raised.value.status_code == 401
assert route.call_count == 0

View file

@ -0,0 +1,309 @@
import base64
import copy
import json
from collections.abc import Mapping
from typing import Final
import pytest
import litellm
from litellm.exceptions import CallerCredentialAuthenticationError
from litellm.llms.chatgpt.common_utils import CHATGPT_API_BASE, ChatGPTAuthError
from litellm.llms.chatgpt.subscription_login import (
attach_chatgpt_subscription_login,
names_chatgpt_subscription_login,
)
from litellm.router_utils.clientside_credential_handler import get_dynamic_litellm_params
from litellm.types.proxy.litellm_pre_call_utils import RedactedDict
from litellm.types.utils import CredentialItem
def _jwt(claims: Mapping[str, object]) -> str:
def segment(value: Mapping[str, object]) -> str:
return base64.urlsafe_b64encode(json.dumps(value).encode()).rstrip(b"=").decode()
return f"{segment({'alg': 'RS256'})}.{segment(claims)}.fake-signature"
TEAM_A_LOGIN: Final = "team-a-chatgpt"
TEAM_B_LOGIN: Final = "team-b-chatgpt"
TEAM_A_TOKEN: Final = _jwt({"https://api.openai.com/auth": {"chatgpt_account_id": "acct-team-a"}, "exp": 4102444800})
TEAM_B_TOKEN: Final = _jwt({"https://api.openai.com/auth": {"chatgpt_account_id": "acct-team-b"}, "exp": 4102444800})
CLIENT_TOKEN: Final = _jwt({"https://api.openai.com/auth": {"chatgpt_account_id": "acct-client"}})
def _subscription(name: str, access_token: str, owner_team_id: str) -> CredentialItem:
return CredentialItem(
credential_name=name,
credential_values={
"chatgpt_access_token": access_token,
"chatgpt_refresh_token": f"refresh-{name}",
"chatgpt_id_token": access_token,
"chatgpt_account_id": f"acct-{owner_team_id}",
"chatgpt_expires_at": "4102444800",
},
credential_info={"custom_llm_provider": "chatgpt", "chatgpt_owner_team_id": owner_team_id},
)
ORDINARY: Final = CredentialItem(
credential_name="plain-openai", credential_values={"api_key": "sk-plain-example"}, credential_info={}
)
@pytest.fixture(autouse=True)
def credentials(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
litellm,
"credential_list",
[
_subscription(TEAM_A_LOGIN, TEAM_A_TOKEN, "team-a"),
_subscription(TEAM_B_LOGIN, TEAM_B_TOKEN, "team-b"),
ORDINARY,
],
)
def _owned(*logins: tuple[str, str]) -> dict[str, object]:
return {"chatgpt_subscription_logins": RedactedDict(dict(logins)), "raw_headers": {}}
def _request(**overrides: object) -> dict[str, object]:
return {
"model": "chatgpt/gpt-5.5",
"litellm_credential_name": TEAM_A_LOGIN,
"chatgpt_auth_file": "/host/chatgpt/auth.json",
"secret_fields": _owned((TEAM_A_LOGIN, TEAM_A_TOKEN)),
**overrides,
}
@pytest.mark.parametrize(
("model", "overrides"),
[
("chatgpt/gpt-5.5", {}),
(None, {}),
("gpt-5.5", {"model": "gpt-5.5", "custom_llm_provider": "chatgpt"}),
],
ids=["model_argument", "model_kwarg", "custom_llm_provider"],
)
def test_attach_sends_the_owned_login_to_chatgpt_instead_of_the_host_auth_file(
model: str | None, overrides: dict[str, object]
) -> None:
kwargs: Final = _request(**overrides)
attach_chatgpt_subscription_login(kwargs, model)
assert kwargs["api_key"] == TEAM_A_TOKEN
assert kwargs["api_base"] == CHATGPT_API_BASE
assert "chatgpt_auth_file" not in kwargs
@pytest.mark.parametrize(
"secret_fields",
[
_owned((TEAM_B_LOGIN, TEAM_B_TOKEN)),
_owned(),
_owned((TEAM_A_LOGIN, "")),
{"raw_headers": {}},
None,
],
ids=["another_teams_login", "no_logins", "empty_token", "no_login_field", "no_secret_fields"],
)
def test_a_login_the_caller_does_not_own_fails_closed_with_a_401(secret_fields: dict[str, object] | None) -> None:
kwargs: Final = _request(secret_fields=secret_fields) if secret_fields is not None else _request()
if secret_fields is None:
kwargs.pop("secret_fields")
before: Final = copy.deepcopy(kwargs)
with pytest.raises(CallerCredentialAuthenticationError) as raised:
attach_chatgpt_subscription_login(kwargs, "chatgpt/gpt-5.5")
assert raised.value.status_code == 401
assert TEAM_A_LOGIN in str(raised.value)
assert TEAM_A_TOKEN not in str(raised.value)
assert TEAM_B_TOKEN not in str(raised.value)
assert kwargs == before
def test_a_chatgpt_deployment_naming_a_missing_credential_fails_closed(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(litellm, "credential_list", [])
kwargs: Final = _request()
with pytest.raises(CallerCredentialAuthenticationError) as raised:
attach_chatgpt_subscription_login(kwargs, "chatgpt/gpt-5.5")
assert raised.value.status_code == 401
assert TEAM_A_TOKEN not in str(raised.value)
assert "api_key" not in kwargs
assert kwargs["chatgpt_auth_file"] == "/host/chatgpt/auth.json"
@pytest.mark.parametrize(
("kwargs", "model"),
[
(
{
"model": "openai/gpt-5.4-mini",
"litellm_credential_name": "deleted-credential",
"secret_fields": _owned(),
},
"openai/gpt-5.4-mini",
),
(
{"model": "chatgpt/gpt-5.5", "litellm_credential_name": "plain-openai", "chatgpt_auth_file": "/a.json"},
"chatgpt/gpt-5.5",
),
(
{
"model": "chatgpt/gpt-5.5",
"litellm_credential_name": "plain-openai",
"secret_fields": _owned(("plain-openai", TEAM_A_TOKEN)),
},
"chatgpt/gpt-5.5",
),
({"model": "chatgpt/gpt-5.5", "secret_fields": _owned((TEAM_A_LOGIN, TEAM_A_TOKEN))}, "chatgpt/gpt-5.5"),
({"model": "chatgpt/gpt-5.5", "litellm_credential_name": ""}, "chatgpt/gpt-5.5"),
],
ids=[
"missing_credential_on_another_provider",
"ordinary_credential",
"ordinary_credential_with_a_same_named_login",
"no_credential_name",
"empty_credential_name",
],
)
def test_requests_that_name_no_subscription_login_are_left_untouched(
kwargs: dict[str, object], model: str
) -> None:
before: Final = copy.deepcopy(kwargs)
attach_chatgpt_subscription_login(kwargs, model)
assert kwargs == before
@pytest.mark.parametrize(
("model", "overrides"),
[
("openai/gpt-5.4-mini", {"model": "openai/gpt-5.4-mini"}),
("gpt-5.4-mini", {"model": "gpt-5.4-mini", "custom_llm_provider": "openai"}),
],
)
def test_a_subscription_login_is_never_sent_to_another_provider(model: str, overrides: dict[str, object]) -> None:
kwargs: Final = _request(**overrides)
with pytest.raises(CallerCredentialAuthenticationError) as raised:
attach_chatgpt_subscription_login(kwargs, model)
assert raised.value.status_code == 401
assert TEAM_A_TOKEN not in str(raised.value)
assert "api_key" not in kwargs
def _login_headers() -> dict[str, str]:
return {
"Authorization": f"Bearer {CLIENT_TOKEN}",
"chatgpt-account-id": "acct-client",
"originator": "codex_cli_rs",
}
def _header_names(headers: object) -> set[str]:
assert isinstance(headers, Mapping)
return {str(name).lower() for name in headers}
@pytest.mark.parametrize("scoped_as_list", [False, True])
def test_attach_strips_a_forwarded_chatgpt_login_from_every_header_carrier(scoped_as_list: bool) -> None:
scoped: Final = {"custom_llm_provider": "chatgpt", "extra_headers": _login_headers()}
kwargs: Final = _request(
provider_specific_header=[scoped, {"custom_llm_provider": "openai", "extra_headers": _login_headers()}]
if scoped_as_list
else scoped,
extra_headers={"AUTHORIZATION": f"Bearer {CLIENT_TOKEN}", "ChatGPT-Account-Id": "acct-client", "x-trace": "1"},
headers={"authorization": f"Bearer {CLIENT_TOKEN}", "x-trace": "1"},
optional_params={"extra_headers": _login_headers(), "temperature": 0.2},
litellm_params={"extra_headers": _login_headers(), "metadata": {"tag": "kept"}},
)
attach_chatgpt_subscription_login(kwargs, "chatgpt/gpt-5.5")
entries: Final = kwargs["provider_specific_header"] if scoped_as_list else [kwargs["provider_specific_header"]]
assert isinstance(entries, list)
expected_providers: Final = ["chatgpt", "openai"] if scoped_as_list else ["chatgpt"]
assert [entry["custom_llm_provider"] for entry in entries] == expected_providers
assert all(_header_names(entry["extra_headers"]) == {"originator"} for entry in entries)
assert _header_names(kwargs["extra_headers"]) == {"x-trace"}
assert _header_names(kwargs["headers"]) == {"x-trace"}
assert kwargs["optional_params"] == {"extra_headers": {"originator": "codex_cli_rs"}, "temperature": 0.2}
assert kwargs["litellm_params"] == {"extra_headers": {"originator": "codex_cli_rs"}, "metadata": {"tag": "kept"}}
assert kwargs["api_key"] == TEAM_A_TOKEN
assert CLIENT_TOKEN not in json.dumps({key: value for key, value in kwargs.items() if key != "secret_fields"})
@pytest.mark.parametrize(
"overrides",
[
{"api_base": "https://attacker.example/backend-api/codex"},
{"base_url": "https://attacker.example/backend-api/codex"},
{"api_base": "http://chatgpt.com/backend-api/codex"},
{"api_base": "https://chatgpt.com.attacker.example/backend-api/codex"},
{"base_url": CHATGPT_API_BASE, "api_base": "https://attacker.example/backend-api/codex"},
{"base_url": "https://attacker.example/backend-api/codex", "api_base": CHATGPT_API_BASE},
],
)
def test_a_client_base_off_chatgpt_is_refused_before_the_login_is_attached(overrides: dict[str, object]) -> None:
kwargs: Final = _request(**overrides)
before: Final = copy.deepcopy(kwargs)
with pytest.raises(ChatGPTAuthError) as raised:
attach_chatgpt_subscription_login(kwargs, "chatgpt/gpt-5.5")
assert raised.value.status_code == 400
assert TEAM_A_TOKEN not in str(raised.value)
assert kwargs == before
@pytest.mark.parametrize("field", ["api_base", "base_url"])
def test_a_client_base_on_chatgpt_is_kept_as_api_base_and_base_url_is_removed(field: str) -> None:
client_base: Final = "https://chatgpt.com:443/backend-api/codex"
kwargs: Final = _request(**{field: client_base})
attach_chatgpt_subscription_login(kwargs, "chatgpt/gpt-5.5")
assert kwargs["api_base"] == client_base
assert "base_url" not in kwargs
assert kwargs["api_key"] == TEAM_A_TOKEN
def test_a_caller_api_base_override_on_a_subscription_deployment_never_sends_the_login_off_chatgpt() -> None:
redirected: Final = get_dynamic_litellm_params(
litellm_params={"model": "chatgpt/gpt-5.5", "litellm_credential_name": TEAM_A_LOGIN},
request_kwargs={"api_base": "https://attacker.example/backend-api/codex"},
)
kwargs: Final = {**redirected, "secret_fields": _owned((TEAM_A_LOGIN, TEAM_A_TOKEN))}
with pytest.raises(ChatGPTAuthError):
attach_chatgpt_subscription_login(kwargs, "chatgpt/gpt-5.5")
assert "api_key" not in kwargs
@pytest.mark.parametrize(
("credential_name", "expected"),
[(TEAM_A_LOGIN, True), ("plain-openai", False), ("deleted-credential", False), ("", False), (None, False)],
)
def test_names_chatgpt_subscription_login(credential_name: object, expected: bool) -> None:
assert names_chatgpt_subscription_login(credential_name) is expected
def test_attach_is_repeatable_on_the_same_request() -> None:
kwargs: Final = _request()
attach_chatgpt_subscription_login(kwargs, "chatgpt/gpt-5.5")
first: Final = dict(kwargs)
attach_chatgpt_subscription_login(kwargs, "chatgpt/gpt-5.5")
assert kwargs == first
assert kwargs["api_key"] == TEAM_A_TOKEN

View file

@ -237,6 +237,110 @@ def test_a_request_body_cannot_pick_a_federated_identity_by_credential_name(monk
)
_CHATGPT_LOGIN_FIELDS: Final = (
"chatgpt_access_token",
"chatgpt_refresh_token",
"chatgpt_id_token",
"chatgpt_account_id",
"chatgpt_expires_at",
)
@pytest.mark.parametrize("field", _CHATGPT_LOGIN_FIELDS)
@pytest.mark.parametrize("nested", [False, True], ids=["top_level", "nested_litellm_params"])
@pytest.mark.parametrize("allow_client_side_credentials", [False, True])
def test_a_request_body_cannot_carry_a_chatgpt_subscription_login_value(
field: str, nested: bool, allow_client_side_credentials: bool
) -> None:
login: Final = {field: "attacker-chosen"}
with pytest.raises(
ValueError, match="server-owned workload identity federation or OAuth token exchange parameter"
) as error:
is_request_body_safe(
request_body={"model": "chatgpt/gpt-5.5", **({"litellm_params": login} if nested else login)},
general_settings={"allow_client_side_credentials": allow_client_side_credentials},
llm_router=None,
model="chatgpt/gpt-5.5",
)
assert field in str(error.value)
assert "attacker-chosen" not in str(error.value)
def test_a_deployment_opt_in_cannot_let_a_request_body_carry_a_chatgpt_refresh_token() -> None:
router: Final = Router(
model_list=[
{
"model_name": "chatgpt/gpt-5.5",
"litellm_params": {
"model": "chatgpt/gpt-5.5",
"configurable_clientside_auth_params": ["chatgpt_refresh_token", "api_key"],
},
}
]
)
with pytest.raises(ValueError, match="chatgpt_refresh_token"):
is_request_body_safe(
request_body={"model": "chatgpt/gpt-5.5", "chatgpt_refresh_token": "attacker-chosen"},
general_settings={},
llm_router=router,
model="chatgpt/gpt-5.5",
)
_NAMES_TEAM_A_LOGIN: Final = {"litellm_credential_name": "team-a-chatgpt"}
_CLIENT_ROUTER_MODEL_NAMING_TEAM_A_LOGIN: Final = {
"model": "chatgpt/gpt-5.5",
"user_config": _client_router_config("model_list", {"model": "chatgpt/gpt-5.5", **_NAMES_TEAM_A_LOGIN}),
}
_CLIENT_ROUTER_DEFAULTS_NAMING_TEAM_A_LOGIN: Final = {
"model": "chatgpt/gpt-5.5",
"user_config": _client_router_config("default_litellm_params", dict(_NAMES_TEAM_A_LOGIN)),
}
@pytest.mark.parametrize(
("body", "allow_client_side_credentials"),
[
pytest.param({"model": "chatgpt/gpt-5.5", **_NAMES_TEAM_A_LOGIN}, False, id="top_level"),
pytest.param({"model": "chatgpt/gpt-5.5", **_NAMES_TEAM_A_LOGIN}, True, id="top_level_opted_in"),
pytest.param({"model": "chatgpt/gpt-5.5", "litellm_params": _NAMES_TEAM_A_LOGIN}, False, id="nested"),
pytest.param(
{"model": "chatgpt/gpt-5.5", "litellm_params": _NAMES_TEAM_A_LOGIN}, True, id="nested_opted_in"
),
pytest.param(_CLIENT_ROUTER_MODEL_NAMING_TEAM_A_LOGIN, True, id="client_router_model_list"),
pytest.param(_CLIENT_ROUTER_DEFAULTS_NAMING_TEAM_A_LOGIN, True, id="client_router_defaults"),
],
)
def test_a_request_body_cannot_pick_a_chatgpt_subscription_login_by_credential_name(
monkeypatch: pytest.MonkeyPatch, body: dict[str, object], allow_client_side_credentials: bool
) -> None:
from litellm.types.utils import CredentialItem
monkeypatch.setattr(
litellm,
"credential_list",
[
CredentialItem(
credential_name="team-a-chatgpt",
credential_values={"chatgpt_access_token": "access-team-a-example", "chatgpt_account_id": "acct-a"},
credential_info={"custom_llm_provider": "chatgpt", "chatgpt_owner_team_id": "team-a"},
)
],
)
with pytest.raises(ValueError, match="names a credential configured for") as error:
is_request_body_safe(
request_body=body,
general_settings={"allow_client_side_credentials": allow_client_side_credentials},
llm_router=None,
model="chatgpt/gpt-5.5",
)
assert "access-team-a-example" not in str(error.value)
def test_a_request_body_may_still_name_a_credential_that_does_not_federate(monkeypatch):
"""Only federation makes a credential a deployment decision. An ordinary named credential stays
usable from a request body, so the ban must read what the credential holds, not its presence."""
@ -3193,6 +3297,33 @@ class TestGetDynamicLitellmParamsClearsAdminConfigOnBaseOverride:
)
assert "github_copilot_auth_type" not in out
@pytest.mark.parametrize("override", ["api_base", "base_url"])
@pytest.mark.parametrize(
"credential_values",
[
pytest.param({"chatgpt_access_token": "access-team-a-example"}, id="chatgpt_subscription_login"),
pytest.param({"chatgpt_account_id": "acct-team-a"}, id="chatgpt_login_without_token_yet"),
],
)
def test_a_chatgpt_subscription_credential_name_survives_a_base_override(
self, override: str, credential_values: dict[str, str]
):
from litellm.router_utils.clientside_credential_handler import get_dynamic_litellm_params
from litellm.types.utils import CredentialItem
with patch.object(
litellm,
"credential_list",
[CredentialItem(credential_name="team-a-login", credential_values=credential_values, credential_info={})],
):
out = get_dynamic_litellm_params(
litellm_params={"model": "chatgpt/gpt-5.5", "litellm_credential_name": "team-a-login"},
request_kwargs={override: "https://attacker.example"},
)
assert out["litellm_credential_name"] == "team-a-login"
assert out[override] == "https://attacker.example"
def test_field_echo_does_not_preserve_admin_value(self):
# Regression: a caller that echoes an admin-config field name with
# an *empty* value (or any value) must not be able to keep the

View file

@ -1,11 +1,14 @@
import asyncio
import base64
import functools
import gc
import itertools
import json
import os
import weakref
from collections.abc import Iterator, Mapping
from datetime import datetime, timezone
from pathlib import Path
from types import MethodType
from typing import Final
from unittest.mock import AsyncMock, MagicMock, patch
@ -3354,3 +3357,169 @@ def test_upsert_deployment_clears_stale_budget_config(monkeypatch):
router.upsert_deployment(deployment=unbudgeted)
assert budget_limiter._get_budget_config_for_deployment(model_id) is None
def test_get_deployment_credentials_with_provider_never_returns_chatgpt_subscription_login_values(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from litellm.types.utils import CredentialItem
login_values: Final = {
"chatgpt_access_token": "access-team-a-example",
"chatgpt_refresh_token": "refresh-team-a-example",
"chatgpt_id_token": "id-team-a-example",
"chatgpt_account_id": "acct-team-a",
"chatgpt_expires_at": "4102444800",
}
monkeypatch.setattr(
litellm,
"credential_list",
[
CredentialItem(
credential_name="team-a-chatgpt",
credential_values={**login_values, "api_version": "2026-01-01"},
credential_info={"custom_llm_provider": "chatgpt", "chatgpt_owner_team_id": "team-a"},
)
],
)
router: Final = Router(
model_list=[
{
"model_name": "team-a-gpt",
"litellm_params": {"model": "chatgpt/gpt-5.5", "litellm_credential_name": "team-a-chatgpt"},
"model_info": {"id": "team-a-subscription"},
}
]
)
credentials: Final = router.get_deployment_credentials_with_provider(model_id="team-a-subscription")
assert credentials == {"model": "chatgpt/gpt-5.5", "custom_llm_provider": "chatgpt", "api_version": "2026-01-01"}
def _fake_chatgpt_jwt(account_id: str) -> str:
claims: Final = {"exp": 4102444800, "https://api.openai.com/auth": {"chatgpt_account_id": account_id}}
payload: Final = base64.urlsafe_b64encode(json.dumps(claims).encode()).decode().rstrip("=")
return f"e30.{payload}.fake-signature"
@pytest.fixture
def chatgpt_logins(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
from litellm.types.utils import CredentialItem
monkeypatch.setenv("CHATGPT_TOKEN_DIR", str(tmp_path))
monkeypatch.delenv("CHATGPT_AUTH_FILE", raising=False)
(tmp_path / "auth.json").write_text(
json.dumps(
{"access_token": _fake_chatgpt_jwt("acct-host"), "account_id": "acct-host", "expires_at": 4102444800}
)
)
monkeypatch.setattr(
litellm,
"credential_list",
[
*(
CredentialItem(
credential_name=name,
credential_values={"chatgpt_access_token": _fake_chatgpt_jwt(f"acct-{name}")},
credential_info={"custom_llm_provider": "chatgpt"},
)
for name in ("team-a-chatgpt", "team-b-chatgpt")
),
CredentialItem(credential_name="plain-key", credential_values={"api_key": "sk-plain"}, credential_info={}),
],
)
_LOCKED_CHATGPT_DEPLOYMENTS: Final = {
"subscription": {"model": "chatgpt/gpt-5.5", "litellm_credential_name": "team-a-chatgpt"},
"subscription_by_provider": {
"model": "gpt-5.5",
"custom_llm_provider": "chatgpt",
"litellm_credential_name": "team-a-chatgpt",
},
"chatgpt_naming_a_credential": {"model": "chatgpt/gpt-5.5", "litellm_credential_name": "plain-key"},
}
_CREDENTIAL_NAME_OVERRIDES: Final = ("", None, "team-b-chatgpt", "plain-key", "unknown-credential")
def _owned_logins() -> dict[str, object]:
from litellm.types.proxy.litellm_pre_call_utils import RedactedDict
return {"chatgpt_subscription_logins": RedactedDict({"team-a-chatgpt": _fake_chatgpt_jwt("acct-team-a-chatgpt")})}
@pytest.mark.asyncio
@pytest.mark.usefixtures("chatgpt_logins")
@pytest.mark.parametrize(
("deployment", "override"),
[
pytest.param(deployment, override, id=f"{name}-{override!r}")
for (name, deployment), override in itertools.product(
_LOCKED_CHATGPT_DEPLOYMENTS.items(), _CREDENTIAL_NAME_OVERRIDES
)
if override != deployment["litellm_credential_name"]
],
)
async def test_a_request_cannot_swap_the_credential_a_chatgpt_deployment_names(
deployment: dict[str, str], override: str | None
) -> None:
router: Final = Router(model_list=[{"model_name": "team-a-gpt", "litellm_params": deployment}])
with pytest.raises(litellm.BadRequestError, match="cannot be overridden on a deployment that uses a ChatGPT"):
await router.acompletion(
model="team-a-gpt",
messages=[{"role": "user", "content": "hi"}],
mock_response="ran",
secret_fields=_owned_logins(),
litellm_credential_name=override,
num_retries=0,
)
@pytest.mark.asyncio
@pytest.mark.usefixtures("chatgpt_logins")
@pytest.mark.parametrize("deployment", _LOCKED_CHATGPT_DEPLOYMENTS.values(), ids=_LOCKED_CHATGPT_DEPLOYMENTS.keys())
@pytest.mark.parametrize("repeats_its_own_name", [False, True])
async def test_a_chatgpt_deployment_runs_when_the_request_leaves_its_credential_alone(
deployment: dict[str, str], repeats_its_own_name: bool
) -> None:
router: Final = Router(model_list=[{"model_name": "team-a-gpt", "litellm_params": deployment}])
own_name: Final = {"litellm_credential_name": deployment["litellm_credential_name"]} if repeats_its_own_name else {}
response: Final = await router.acompletion(
model="team-a-gpt",
messages=[{"role": "user", "content": "hi"}],
mock_response="ran",
secret_fields=_owned_logins(),
num_retries=0,
**own_name,
)
assert response.choices[0].message.content == "ran"
@pytest.mark.asyncio
@pytest.mark.usefixtures("chatgpt_logins")
@pytest.mark.parametrize("override", ["", None, "unknown-credential"])
async def test_a_deployment_that_is_not_chatgpt_keeps_its_existing_credential_override_behavior(
override: str | None,
) -> None:
router: Final = Router(
model_list=[
{
"model_name": "plain-gpt",
"litellm_params": {"model": "openai/gpt-5.4-mini", "litellm_credential_name": "plain-key"},
}
]
)
response: Final = await router.acompletion(
model="plain-gpt",
messages=[{"role": "user", "content": "hi"}],
mock_response="ran",
litellm_credential_name=override,
num_retries=0,
)
assert response.choices[0].message.content == "ran"

View file

@ -7183,6 +7183,124 @@ def test_load_credentials_from_list_fills_kwargs_from_the_loaded_credential_with
assert _credential_warnings(caplog) == []
_CHATGPT_SUBSCRIPTION_LOGIN_VALUES: Final[Mapping[str, str]] = {
"chatgpt_access_token": "access-team-a-example",
"chatgpt_refresh_token": "refresh-team-a-example",
"chatgpt_id_token": "id-team-a-example",
"chatgpt_account_id": "acct-team-a",
"chatgpt_expires_at": "4102444800",
}
def _chatgpt_subscription_credential(**extra_values: str) -> CredentialItem:
return CredentialItem(
credential_name="team-a-chatgpt",
credential_values={**_CHATGPT_SUBSCRIPTION_LOGIN_VALUES, **extra_values},
credential_info={"custom_llm_provider": "chatgpt", "chatgpt_owner_team_id": "team-a"},
)
def test_load_credentials_from_list_never_merges_chatgpt_subscription_login_values_into_kwargs(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from litellm.utils import load_credentials_from_list, with_named_credential
monkeypatch.setattr(litellm, "credential_list", [_chatgpt_subscription_credential(api_version="2026-01-01")])
request_kwargs: Final[dict[str, object]] = {
"litellm_credential_name": "team-a-chatgpt",
"model": "chatgpt/gpt-5.5",
}
load_credentials_from_list(request_kwargs)
assert request_kwargs == {
"litellm_credential_name": "team-a-chatgpt",
"model": "chatgpt/gpt-5.5",
"api_version": "2026-01-01",
}
assert with_named_credential({"litellm_credential_name": "team-a-chatgpt"}) == {
"litellm_credential_name": "team-a-chatgpt",
"api_version": "2026-01-01",
}
@pytest.mark.asyncio
@pytest.mark.parametrize(
"secret_fields",
[
pytest.param(None, id="no_secret_fields"),
pytest.param({"chatgpt_subscription_logins": {}}, id="no_owned_logins"),
pytest.param({"chatgpt_subscription_logins": {"team-b-chatgpt": "access-team-b-example"}}, id="another_login"),
],
)
async def test_acompletion_on_a_chatgpt_subscription_the_caller_does_not_own_is_a_401(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, secret_fields: dict[str, object] | None
) -> None:
monkeypatch.setenv("CHATGPT_TOKEN_DIR", str(tmp_path))
monkeypatch.setattr(litellm, "credential_list", [_chatgpt_subscription_credential()])
with pytest.raises(litellm.CallerCredentialAuthenticationError) as raised:
await litellm.acompletion(
model="chatgpt/gpt-5.5",
messages=[{"role": "user", "content": "hi"}],
litellm_credential_name="team-a-chatgpt",
mock_response="owned",
**({} if secret_fields is None else {"secret_fields": secret_fields}),
)
assert raised.value.status_code == 401
assert "team-a-chatgpt" in str(raised.value)
assert not any(token in str(raised.value) for token in _CHATGPT_SUBSCRIPTION_LOGIN_VALUES.values())
@pytest.mark.asyncio
@pytest.mark.parametrize("through_router", [False, True], ids=["direct", "through_router"])
async def test_acompletion_on_a_chatgpt_subscription_the_caller_owns_returns_the_response_with_no_host_login(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, through_router: bool
) -> None:
from litellm.types.proxy.litellm_pre_call_utils import RedactedDict
monkeypatch.setenv("CHATGPT_TOKEN_DIR", str(tmp_path))
monkeypatch.setattr(litellm, "credential_list", [_chatgpt_subscription_credential()])
request: Final = {
"messages": [{"role": "user", "content": "hi"}],
"mock_response": "owned",
"secret_fields": {"chatgpt_subscription_logins": RedactedDict({"team-a-chatgpt": "access-team-a-example"})},
}
deployment: Final = {"model": "chatgpt/gpt-5.5", "litellm_credential_name": "team-a-chatgpt"}
router: Final = litellm.Router(model_list=[{"model_name": "team-a-gpt", "litellm_params": deployment}])
response: Final = await (
router.acompletion(model="team-a-gpt", **request)
if through_router
else litellm.acompletion(**deployment, **request)
)
assert response.choices[0].message.content == "owned"
@pytest.mark.asyncio
@pytest.mark.parametrize("through_router", [False, True], ids=["direct", "through_router"])
async def test_aresponses_on_a_chatgpt_subscription_the_caller_owns_returns_the_response_with_no_host_login(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, through_router: bool
) -> None:
from litellm.types.proxy.litellm_pre_call_utils import RedactedDict
monkeypatch.setenv("CHATGPT_TOKEN_DIR", str(tmp_path))
monkeypatch.setattr(litellm, "credential_list", [_chatgpt_subscription_credential()])
request: Final = {
"input": "hi",
"mock_response": "owned",
"secret_fields": {"chatgpt_subscription_logins": RedactedDict({"team-a-chatgpt": "access-team-a-example"})},
}
deployment: Final = {"model": "chatgpt/gpt-5.5", "litellm_credential_name": "team-a-chatgpt"}
router: Final = litellm.Router(model_list=[{"model_name": "team-a-gpt", "litellm_params": deployment}])
response: Final = await (
router.aresponses(model="team-a-gpt", **request)
if through_router
else litellm.aresponses(**deployment, **request)
)
assert response.output[0].content[0].text == "owned"
_MISTRAL_CHAT_URL: Final = "https://api.mistral.ai/v1/chat/completions"
_MISTRAL_REPLY: Final = {
"id": "chatcmpl-oidc",

View file

@ -23,6 +23,7 @@ from litellm.types.utils import (
CustomPricingLiteLLMParams,
MirroredPricingParams,
anthropic_wif_litellm_params,
chatgpt_subscription_login_litellm_params,
github_copilot_oauth_litellm_params,
oauth_token_exchange_litellm_params,
openai_wif_litellm_params,
@ -368,6 +369,7 @@ def test_server_owned_registry_includes_anthropic_openai_and_oauth_token_exchang
+ oauth_token_exchange_litellm_params
+ github_copilot_oauth_litellm_params
+ ("chatgpt_auth_file",)
+ chatgpt_subscription_login_litellm_params
)
assert set(openai_wif_litellm_params) == {
"openai_identity_provider_id",