From 64bcb9e95941fb68cbb4749229a624d11333502b Mon Sep 17 00:00:00 2001 From: hx <1367557521@qq.com> Date: Tue, 8 Sep 2026 11:16:18 +0000 Subject: [PATCH 01/15] feat(xai): support multi-account SuperGrok OAuth with automatic failover Add per-deployment xai_oauth_token_file so multiple SuperGrok accounts can share one proxy. Map xAI 403 spending-limit to RateLimitError so the router can fail over to another deployment Co-authored-by: HX --- .../exception_mapping_utils.py | 8 + .../litellm_core_utils/get_litellm_params.py | 1 + litellm/llms/xai/chat/transformation.py | 8 +- litellm/llms/xai/oauth.py | 19 +- litellm/llms/xai/responses/transformation.py | 7 +- litellm/main.py | 1 + litellm/proxy/proxy_cli.py | 13 +- litellm/types/litellm_params.py | 1 + litellm/types/router.py | 8 + .../llms/xai/test_xai_oauth_multi_account.py | 224 ++++++++++++++++++ 10 files changed, 281 insertions(+), 9 deletions(-) create mode 100644 tests/unit/llms/xai/test_xai_oauth_multi_account.py diff --git a/litellm/litellm_core_utils/exception_mapping_utils.py b/litellm/litellm_core_utils/exception_mapping_utils.py index 85c232ad3f4..fb27c5c95d6 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -514,6 +514,14 @@ def _map_openai_exception( response=response, litellm_debug_info=extra_information, ) + elif original_exception.status_code == 403 and "spending-limit" in error_str: + raise RateLimitError( + message=f"RateLimitError: {exception_provider} - {message}", + model=model, + llm_provider=custom_llm_provider, + response=getattr(original_exception, "response", None), + litellm_debug_info=extra_information, + ) elif original_exception.status_code == 404: raise NotFoundError( message=f"NotFoundError: {exception_provider} - {message}", diff --git a/litellm/litellm_core_utils/get_litellm_params.py b/litellm/litellm_core_utils/get_litellm_params.py index 29f96d61976..0df30f8d352 100644 --- a/litellm/litellm_core_utils/get_litellm_params.py +++ b/litellm/litellm_core_utils/get_litellm_params.py @@ -87,6 +87,7 @@ OPTIONAL_KWARGS_KEYS: Final = ( "responses_api_version", "allowed_openai_params", PROVIDER_AFFINITY_HEADER_KWARG_KEY, + "xai_oauth_token_file", } ) | AWS_CREDENTIAL_KWARGS_KEYS diff --git a/litellm/llms/xai/chat/transformation.py b/litellm/llms/xai/chat/transformation.py index 2b943fa4107..89a4c406f10 100644 --- a/litellm/llms/xai/chat/transformation.py +++ b/litellm/llms/xai/chat/transformation.py @@ -79,8 +79,10 @@ class XAIChatConfig(OpenAIGPTConfig): dynamic_api_key: Final = XAIModelInfo.get_api_key(api_key) if should_use_xai_oauth(litellm_params) and not dynamic_api_key: + raw_token_file: Final = (litellm_params or {}).get("xai_oauth_token_file") + token_file: Final = raw_token_file if isinstance(raw_token_file, str) else None try: - headers["Authorization"] = f"Bearer {XAIOAuthAuthenticator().get_access_token()}" + headers["Authorization"] = f"Bearer {XAIOAuthAuthenticator(auth_file=token_file).get_access_token()}" except XAIOAuthError as exc: raise AuthenticationError( model=model, @@ -114,7 +116,9 @@ class XAIChatConfig(OpenAIGPTConfig): dynamic_api_key: Final = XAIModelInfo.get_api_key(api_key) if should_use_xai_oauth(litellm_params) and not dynamic_api_key: - api_base = XAIOAuthAuthenticator().get_api_base() + raw_token_file: Final = (litellm_params or {}).get("xai_oauth_token_file") + token_file: Final = raw_token_file if isinstance(raw_token_file, str) else None + api_base = XAIOAuthAuthenticator(auth_file=token_file).get_api_base() return super().get_complete_url( api_base=api_base, diff --git a/litellm/llms/xai/oauth.py b/litellm/llms/xai/oauth.py index 56dcc5a0c90..7678bc5169d 100644 --- a/litellm/llms/xai/oauth.py +++ b/litellm/llms/xai/oauth.py @@ -121,9 +121,22 @@ class _CallbackServer(HTTPServer): class XAIOAuthAuthenticator: - def __init__(self, http_client: httpx.Client | HTTPHandler | None = None) -> None: - self.token_dir = get_secret_str("XAI_OAUTH_TOKEN_DIR") or os.path.expanduser("~/.config/litellm/xai_oauth") - self.auth_file = os.path.join(self.token_dir, get_secret_str("XAI_OAUTH_AUTH_FILE") or "auth.json") + def __init__( + self, + http_client: httpx.Client | HTTPHandler | None = None, + auth_file: str | None = None, + token_dir: str | None = None, + ) -> None: + self.token_dir = ( + token_dir or get_secret_str("XAI_OAUTH_TOKEN_DIR") or os.path.expanduser("~/.config/litellm/xai_oauth") + ) + if auth_file: + self.auth_file = auth_file if os.path.isabs(auth_file) else os.path.join(self.token_dir, auth_file) + else: + self.auth_file = os.path.join( + self.token_dir, + get_secret_str("XAI_OAUTH_AUTH_FILE") or "auth.json", + ) self.http_client = http_client def get_api_base(self) -> str: diff --git a/litellm/llms/xai/responses/transformation.py b/litellm/llms/xai/responses/transformation.py index c907fde9926..4498148f965 100644 --- a/litellm/llms/xai/responses/transformation.py +++ b/litellm/llms/xai/responses/transformation.py @@ -213,8 +213,9 @@ class XAIResponsesAPIConfig(OpenAIResponsesAPIConfig): ) if should_use_xai_oauth(litellm_params.model_dump()): + token_file: Final = litellm_params.xai_oauth_token_file try: - api_key = XAIOAuthAuthenticator().get_access_token() + api_key = XAIOAuthAuthenticator(auth_file=token_file).get_access_token() except XAIOAuthError as exc: raise AuthenticationError( model=model, @@ -250,7 +251,9 @@ class XAIResponsesAPIConfig(OpenAIResponsesAPIConfig): api_key: Final = XAIModelInfo.get_api_key(litellm_params.get("api_key"), legacy_generic_before_env=True) if should_use_xai_oauth(litellm_params) and not api_key: - api_base = XAIOAuthAuthenticator().get_api_base() + raw_token_file: Final = litellm_params.get("xai_oauth_token_file") + token_file: Final = raw_token_file if isinstance(raw_token_file, str) else None + api_base = XAIOAuthAuthenticator(auth_file=token_file).get_api_base() else: api_base = api_base or litellm.api_base or get_secret_str("XAI_API_BASE") or XAI_API_BASE diff --git a/litellm/main.py b/litellm/main.py index 5346741e4d1..42d0e1a7d53 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -6205,6 +6205,7 @@ def completion( rpm=kwargs.get("rpm"), use_xai_oauth=kwargs.get("use_xai_oauth", False), chatgpt_auth_file=routing_kwargs.get("chatgpt_auth_file"), + xai_oauth_token_file=kwargs.get("xai_oauth_token_file"), gigachat_scope=kwargs.get("gigachat_scope"), gigachat_auth_url=kwargs.get("gigachat_auth_url"), gigachat_access_token=kwargs.get("gigachat_access_token"), diff --git a/litellm/proxy/proxy_cli.py b/litellm/proxy/proxy_cli.py index 1431a242ebf..2ea301e3109 100644 --- a/litellm/proxy/proxy_cli.py +++ b/litellm/proxy/proxy_cli.py @@ -1263,10 +1263,19 @@ def run_server( prometheus_metrics_port: int | None, ): if cli_args: - if cli_args == ("xai-oauth", "login"): + if len(cli_args) >= 2 and cli_args[0] == "xai-oauth" and cli_args[1] == "login": from litellm.llms.xai.oauth import XAIOAuthAuthenticator + from litellm.secret_managers.main import get_secret_str - authenticator: Final = XAIOAuthAuthenticator() + account: Final = cli_args[2] if len(cli_args) >= 3 else None + token_dir: Final = get_secret_str("XAI_OAUTH_TOKEN_DIR") or os.path.expanduser( + "~/.config/litellm/xai_oauth" + ) + authenticator: Final = ( + XAIOAuthAuthenticator(auth_file=os.path.join(token_dir, f"auth-{account}.json")) + if account + else XAIOAuthAuthenticator() + ) auth_data: Final = authenticator.login() click.echo(f"xAI OAuth login successful. Credentials saved to {authenticator.auth_file}.") if auth_data.get("expires_at"): diff --git a/litellm/types/litellm_params.py b/litellm/types/litellm_params.py index 3c2c636b5ed..8bf92086f3e 100644 --- a/litellm/types/litellm_params.py +++ b/litellm/types/litellm_params.py @@ -101,6 +101,7 @@ class ProviderConnection: token_exchange_scope: str | None = None token_exchange_audience: str | None = None fireworks_forward_user_id: bool | None = None + xai_oauth_token_file: str | None = None @dataclass(frozen=True, slots=True, kw_only=True) diff --git a/litellm/types/router.py b/litellm/types/router.py index 726a328e913..17850ef6356 100644 --- a/litellm/types/router.py +++ b/litellm/types/router.py @@ -610,6 +610,14 @@ class GenericLiteLLMParams(CredentialLiteLLMParams, CustomPricingLiteLLMParams): default=None, description="Send the LiteLLM user id of the calling key as the `user` field on Fireworks AI chat, responses and messages requests.", ) + xai_oauth_token_file: str | None = Field( + default=None, + description=( + "Per-deployment xAI OAuth token file path. When set with use_xai_oauth=True, " + "the request reads that account's OAuth credentials instead of the global " + "token file, enabling multi-account SuperGrok routing." + ), + ) model_config = ConfigDict(extra="allow", arbitrary_types_allowed=True) merge_reasoning_content_in_choices: bool | None = False model_info: dict | None = None diff --git a/tests/unit/llms/xai/test_xai_oauth_multi_account.py b/tests/unit/llms/xai/test_xai_oauth_multi_account.py new file mode 100644 index 00000000000..5f3bcde819f --- /dev/null +++ b/tests/unit/llms/xai/test_xai_oauth_multi_account.py @@ -0,0 +1,224 @@ +import json +import os +import time + +import pytest +from click.testing import CliRunner + +import litellm +from litellm.litellm_core_utils.exception_mapping_utils import _map_openai_exception +from litellm.llms.xai.chat.transformation import XAIChatConfig +from litellm.llms.xai.oauth import XAIOAuthAuthenticator +from litellm.llms.xai.responses.transformation import XAIResponsesAPIConfig +from litellm.types.router import GenericLiteLLMParams + + +def test_authenticator_accepts_explicit_absolute_auth_file(tmp_path): + custom = tmp_path / "custom-dir" / "alice.json" + custom.parent.mkdir(parents=True) + custom.write_text( + json.dumps( + { + "access_token": "alice-token", + "refresh_token": "refresh-token", + "expires_at": time.time() + 3600, + } + ) + ) + + auth = XAIOAuthAuthenticator(auth_file=str(custom)) + + assert auth.auth_file == str(custom) + assert auth.get_access_token() == "alice-token" + + +def test_authenticator_relative_auth_file_joins_token_dir(tmp_path, monkeypatch): + token_dir = tmp_path / "xai_oauth" + token_dir.mkdir() + (token_dir / "auth-bob.json").write_text( + json.dumps( + { + "access_token": "bob-token", + "refresh_token": "refresh-token", + "expires_at": time.time() + 3600, + } + ) + ) + monkeypatch.setenv("XAI_OAUTH_TOKEN_DIR", str(token_dir)) + + auth = XAIOAuthAuthenticator(auth_file="auth-bob.json") + + assert auth.auth_file == str(token_dir / "auth-bob.json") + assert auth.get_access_token() == "bob-token" + + +def test_authenticator_explicit_auth_file_overrides_env(tmp_path, monkeypatch): + env_file = tmp_path / "env.json" + env_file.write_text( + json.dumps( + { + "access_token": "env-token", + "refresh_token": "r", + "expires_at": time.time() + 3600, + } + ) + ) + explicit_file = tmp_path / "explicit.json" + explicit_file.write_text( + json.dumps( + { + "access_token": "explicit-token", + "refresh_token": "r", + "expires_at": time.time() + 3600, + } + ) + ) + monkeypatch.setenv("XAI_OAUTH_AUTH_FILE", str(env_file)) + + auth = XAIOAuthAuthenticator(auth_file=str(explicit_file)) + + assert auth.get_access_token() == "explicit-token" + + +def test_chat_config_uses_per_deployment_token_file(tmp_path): + alice_file = tmp_path / "auth-alice.json" + alice_file.write_text( + json.dumps( + { + "access_token": "alice-chat-token", + "refresh_token": "refresh-token", + "expires_at": time.time() + 3600, + } + ) + ) + + headers = XAIChatConfig().validate_environment( + headers={}, + model="grok-4", + messages=[], + optional_params={}, + litellm_params={ + "use_xai_oauth": True, + "xai_oauth_token_file": str(alice_file), + }, + api_key=None, + ) + + assert headers["Authorization"] == "Bearer alice-chat-token" + + +def test_chat_config_multi_deployment_token_isolation(tmp_path): + alice = tmp_path / "auth-alice.json" + alice.write_text( + json.dumps( + { + "access_token": "alice-token", + "refresh_token": "r", + "expires_at": time.time() + 3600, + } + ) + ) + bob = tmp_path / "auth-bob.json" + bob.write_text( + json.dumps( + { + "access_token": "bob-token", + "refresh_token": "r", + "expires_at": time.time() + 3600, + } + ) + ) + + headers_alice = XAIChatConfig().validate_environment( + headers={}, + model="grok-4", + messages=[], + optional_params={}, + litellm_params={ + "use_xai_oauth": True, + "xai_oauth_token_file": str(alice), + }, + api_key=None, + ) + headers_bob = XAIChatConfig().validate_environment( + headers={}, + model="grok-4", + messages=[], + optional_params={}, + litellm_params={ + "use_xai_oauth": True, + "xai_oauth_token_file": str(bob), + }, + api_key=None, + ) + + assert headers_alice["Authorization"] == "Bearer alice-token" + assert headers_bob["Authorization"] == "Bearer bob-token" + + +def test_responses_config_uses_per_deployment_token_file(tmp_path): + bob = tmp_path / "auth-bob.json" + bob.write_text( + json.dumps( + { + "access_token": "bob-responses-token", + "refresh_token": "r", + "expires_at": time.time() + 3600, + } + ) + ) + + headers = XAIResponsesAPIConfig().validate_environment( + headers={}, + model="grok-4", + litellm_params=GenericLiteLLMParams( + use_xai_oauth=True, + xai_oauth_token_file=str(bob), + ), + ) + + assert headers["Authorization"] == "Bearer bob-responses-token" + + +def test_proxy_cli_xai_oauth_login_with_account(monkeypatch, tmp_path): + from litellm.proxy.proxy_cli import run_server + + captured: dict[str, str | None] = {} + + class FakeAuthenticator: + def __init__(self, http_client=None, auth_file=None, token_dir=None): + captured["auth_file"] = auth_file + self.auth_file = auth_file or "/tmp/xai-oauth-auth.json" + + def login(self): + return {"expires_at": 1234567890} + + monkeypatch.setattr("litellm.llms.xai.oauth.XAIOAuthAuthenticator", FakeAuthenticator) + monkeypatch.setenv("XAI_OAUTH_TOKEN_DIR", str(tmp_path)) + + result = CliRunner().invoke(run_server, ["xai-oauth", "login", "alice"]) + + assert result.exit_code == 0 + expected = os.path.join(str(tmp_path), "auth-alice.json") + assert captured["auth_file"] == expected + assert expected in result.output + + +def test_spending_limit_403_maps_to_rate_limit_error(): + class FakeHTTPError(Exception): + def __init__(self): + self.status_code = 403 + self.message = "personal-team-blocked:spending-limit" + self.response = None + super().__init__(self.message) + + with pytest.raises(litellm.RateLimitError): + _map_openai_exception( + model="grok-4", + original_exception=FakeHTTPError(), + custom_llm_provider="xai", + error_str="personal-team-blocked:spending-limit", + exception_type="APIStatusError", + exception_provider="XaiException", + extra_information="", + ) From 36acc3e053789de76d0c50ab7dd4cddea59c3ed5 Mon Sep 17 00:00:00 2001 From: hx <1367557521@qq.com> Date: Wed, 9 Sep 2026 15:14:46 +0800 Subject: [PATCH 02/15] fix(xai): treat oauth token file as operator-only config --- .../exception_mapping_utils.py | 25 +++- litellm/llms/xai/oauth.py | 38 ++++-- litellm/proxy/auth/auth_utils.py | 5 + litellm/proxy/proxy_cli.py | 7 +- .../llms/xai/test_xai_oauth_multi_account.py | 115 ++++++++++++++---- tests/unit/proxy/auth/test_auth_utils.py | 51 ++++++++ .../auth/test_banned_params_extra_body.py | 1 + 7 files changed, 199 insertions(+), 43 deletions(-) diff --git a/litellm/litellm_core_utils/exception_mapping_utils.py b/litellm/litellm_core_utils/exception_mapping_utils.py index fb27c5c95d6..597f414747f 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -514,12 +514,27 @@ def _map_openai_exception( response=response, litellm_debug_info=extra_information, ) - elif original_exception.status_code == 403 and "spending-limit" in error_str: - raise RateLimitError( - message=f"RateLimitError: {exception_provider} - {message}", - model=model, + elif original_exception.status_code == 403: + from litellm.llms.xai.oauth import is_xai_spending_limit_error + + if is_xai_spending_limit_error( + custom_llm_provider=custom_llm_provider, + status_code=original_exception.status_code, + error_str=error_str, + ): + raise RateLimitError( + message=f"RateLimitError: {exception_provider} - {message}", + model=model, + llm_provider=custom_llm_provider, + response=getattr(original_exception, "response", None), + litellm_debug_info=extra_information, + ) + raise APIError( + status_code=original_exception.status_code, + message=f"APIError: {exception_provider} - {message}", llm_provider=custom_llm_provider, - response=getattr(original_exception, "response", None), + model=model, + request=getattr(original_exception, "request", None), litellm_debug_info=extra_information, ) elif original_exception.status_code == 404: diff --git a/litellm/llms/xai/oauth.py b/litellm/llms/xai/oauth.py index 7678bc5169d..019c98c8450 100644 --- a/litellm/llms/xai/oauth.py +++ b/litellm/llms/xai/oauth.py @@ -2,6 +2,7 @@ import base64 import hashlib import json import os +import re import secrets import sys import threading @@ -30,6 +31,7 @@ XAI_OAUTH_REDIRECT_PORT: Final = 56121 XAI_OAUTH_REDIRECT_PATH: Final = "/callback" XAI_OAUTH_EXPIRY_SKEW_SECONDS: Final = 120 XAI_OAUTH_CALLBACK_TIMEOUT_SECONDS: Final = 180 +_XAI_OAUTH_ACCOUNT_NAME_RE: Final = re.compile(r"^[A-Za-z0-9_-]+$") _XAI_OAUTH_REFRESH_LOCK: Final = threading.Lock() @@ -75,6 +77,30 @@ class XAIOAuthLoginRequiredError(XAIOAuthError): pass +def _default_xai_oauth_token_dir() -> str: + return get_secret_str("XAI_OAUTH_TOKEN_DIR") or os.path.expanduser("~/.config/litellm/xai_oauth") + + +def resolve_xai_oauth_auth_file(auth_file: str | None, token_dir: str) -> str: + requested: Final = auth_file or get_secret_str("XAI_OAUTH_AUTH_FILE") or "auth.json" + candidate: Final = requested if os.path.isabs(requested) else os.path.join(token_dir, requested) + resolved: Final = os.path.realpath(candidate) + allowed: Final = os.path.realpath(token_dir) + if resolved == allowed or resolved.startswith(allowed + os.sep): + return resolved + raise XAIOAuthError("xAI OAuth auth file must stay inside the token directory") + + +def oauth_auth_file_for_account(account: str, token_dir: str) -> str: + if not _XAI_OAUTH_ACCOUNT_NAME_RE.fullmatch(account): + raise ValueError("xAI OAuth account must match ^[A-Za-z0-9_-]+$") + return resolve_xai_oauth_auth_file(f"auth-{account}.json", token_dir) + + +def is_xai_spending_limit_error(*, custom_llm_provider: str, status_code: int, error_str: str) -> bool: + return custom_llm_provider == "xai" and status_code == 403 and "spending-limit" in error_str + + class _CallbackHandler(BaseHTTPRequestHandler): server: "_CallbackServer" # pyright: ignore[reportIncompatibleVariableOverride] # stdlib stubs type server as BaseServer; _CallbackServer is the only server this handler is registered on @@ -127,16 +153,8 @@ class XAIOAuthAuthenticator: auth_file: str | None = None, token_dir: str | None = None, ) -> None: - self.token_dir = ( - token_dir or get_secret_str("XAI_OAUTH_TOKEN_DIR") or os.path.expanduser("~/.config/litellm/xai_oauth") - ) - if auth_file: - self.auth_file = auth_file if os.path.isabs(auth_file) else os.path.join(self.token_dir, auth_file) - else: - self.auth_file = os.path.join( - self.token_dir, - get_secret_str("XAI_OAUTH_AUTH_FILE") or "auth.json", - ) + self.token_dir = token_dir or _default_xai_oauth_token_dir() + self.auth_file = resolve_xai_oauth_auth_file(auth_file, self.token_dir) self.http_client = http_client def get_api_base(self) -> str: diff --git a/litellm/proxy/auth/auth_utils.py b/litellm/proxy/auth/auth_utils.py index 9350c9c935c..05b80c380dd 100644 --- a/litellm/proxy/auth/auth_utils.py +++ b/litellm/proxy/auth/auth_utils.py @@ -417,6 +417,11 @@ _BANNED_REQUEST_BODY_PARAMS: Final[tuple[str, ...]] = ( # caller-supplied value is the same exfil shape as # ``aws_web_identity_token`` on the Bedrock path. "azure_ad_token", + # xAI SuperGrok OAuth token file. The xAI transformer reads + # ``xai_oauth_token_file`` and authenticates as that local account, + # so a caller-supplied path is the same credential-selector shape as + # ``aws_profile_name`` on the Bedrock path. + "xai_oauth_token_file", # Endpoint-targeting fields that retarget the outbound request or # an observability callback. An attacker-controlled value either # exfiltrates the request payload (incl. messages + admin-set diff --git a/litellm/proxy/proxy_cli.py b/litellm/proxy/proxy_cli.py index 2ea301e3109..7b4c6933939 100644 --- a/litellm/proxy/proxy_cli.py +++ b/litellm/proxy/proxy_cli.py @@ -1264,7 +1264,10 @@ def run_server( ): if cli_args: if len(cli_args) >= 2 and cli_args[0] == "xai-oauth" and cli_args[1] == "login": - from litellm.llms.xai.oauth import XAIOAuthAuthenticator + from litellm.llms.xai.oauth import ( + XAIOAuthAuthenticator, + oauth_auth_file_for_account, + ) from litellm.secret_managers.main import get_secret_str account: Final = cli_args[2] if len(cli_args) >= 3 else None @@ -1272,7 +1275,7 @@ def run_server( "~/.config/litellm/xai_oauth" ) authenticator: Final = ( - XAIOAuthAuthenticator(auth_file=os.path.join(token_dir, f"auth-{account}.json")) + XAIOAuthAuthenticator(auth_file=oauth_auth_file_for_account(account, token_dir), token_dir=token_dir) if account else XAIOAuthAuthenticator() ) diff --git a/tests/unit/llms/xai/test_xai_oauth_multi_account.py b/tests/unit/llms/xai/test_xai_oauth_multi_account.py index 5f3bcde819f..254b6cd151d 100644 --- a/tests/unit/llms/xai/test_xai_oauth_multi_account.py +++ b/tests/unit/llms/xai/test_xai_oauth_multi_account.py @@ -8,7 +8,7 @@ from click.testing import CliRunner import litellm from litellm.litellm_core_utils.exception_mapping_utils import _map_openai_exception from litellm.llms.xai.chat.transformation import XAIChatConfig -from litellm.llms.xai.oauth import XAIOAuthAuthenticator +from litellm.llms.xai.oauth import XAIOAuthAuthenticator, XAIOAuthError from litellm.llms.xai.responses.transformation import XAIResponsesAPIConfig from litellm.types.router import GenericLiteLLMParams @@ -26,9 +26,9 @@ def test_authenticator_accepts_explicit_absolute_auth_file(tmp_path): ) ) - auth = XAIOAuthAuthenticator(auth_file=str(custom)) + auth = XAIOAuthAuthenticator(auth_file=str(custom), token_dir=str(custom.parent)) - assert auth.auth_file == str(custom) + assert auth.auth_file == os.path.realpath(str(custom)) assert auth.get_access_token() == "alice-token" @@ -48,10 +48,62 @@ def test_authenticator_relative_auth_file_joins_token_dir(tmp_path, monkeypatch) auth = XAIOAuthAuthenticator(auth_file="auth-bob.json") - assert auth.auth_file == str(token_dir / "auth-bob.json") + assert auth.auth_file == os.path.realpath(str(token_dir / "auth-bob.json")) assert auth.get_access_token() == "bob-token" +def test_authenticator_rejects_relative_path_escaping_token_dir(tmp_path, monkeypatch): + token_dir = tmp_path / "xai_oauth" + token_dir.mkdir() + monkeypatch.setenv("XAI_OAUTH_TOKEN_DIR", str(token_dir)) + + with pytest.raises(XAIOAuthError, match="token directory"): + XAIOAuthAuthenticator(auth_file="../secret.json") + + +def test_authenticator_rejects_absolute_path_outside_token_dir(tmp_path, monkeypatch): + token_dir = tmp_path / "xai_oauth" + token_dir.mkdir() + outside = tmp_path / "outside.json" + outside.write_text("{}") + monkeypatch.setenv("XAI_OAUTH_TOKEN_DIR", str(token_dir)) + + with pytest.raises(XAIOAuthError, match="token directory"): + XAIOAuthAuthenticator(auth_file=str(outside)) + + +def test_authenticator_rejects_dotdot_absolute_path_outside_token_dir(tmp_path, monkeypatch): + token_dir = tmp_path / "xai_oauth" + token_dir.mkdir() + monkeypatch.setenv("XAI_OAUTH_TOKEN_DIR", str(token_dir)) + escaped = str((token_dir / ".." / "outside.json").resolve()) + + with pytest.raises(XAIOAuthError, match="token directory"): + XAIOAuthAuthenticator(auth_file=escaped) + + +def test_oauth_auth_file_for_account_builds_path_inside_token_dir(tmp_path): + from litellm.llms.xai.oauth import oauth_auth_file_for_account + + assert oauth_auth_file_for_account("alice", str(tmp_path)) == os.path.realpath( + os.path.join(str(tmp_path), "auth-alice.json") + ) + assert oauth_auth_file_for_account("Bob_1-2", str(tmp_path)) == os.path.realpath( + os.path.join(str(tmp_path), "auth-Bob_1-2.json") + ) + + +@pytest.mark.parametrize( + "account", + ["", ".", "..", "foo/bar", "../alice", "alice.json", "foo\\bar", "alice/../bob"], +) +def test_oauth_auth_file_for_account_rejects_unsafe_names(account, tmp_path): + from litellm.llms.xai.oauth import oauth_auth_file_for_account + + with pytest.raises(ValueError, match="account"): + oauth_auth_file_for_account(account, str(tmp_path)) + + def test_authenticator_explicit_auth_file_overrides_env(tmp_path, monkeypatch): env_file = tmp_path / "env.json" env_file.write_text( @@ -75,12 +127,13 @@ def test_authenticator_explicit_auth_file_overrides_env(tmp_path, monkeypatch): ) monkeypatch.setenv("XAI_OAUTH_AUTH_FILE", str(env_file)) - auth = XAIOAuthAuthenticator(auth_file=str(explicit_file)) + auth = XAIOAuthAuthenticator(auth_file=str(explicit_file), token_dir=str(tmp_path)) assert auth.get_access_token() == "explicit-token" -def test_chat_config_uses_per_deployment_token_file(tmp_path): +def test_chat_config_uses_per_deployment_token_file(tmp_path, monkeypatch): + monkeypatch.setenv("XAI_OAUTH_TOKEN_DIR", str(tmp_path)) alice_file = tmp_path / "auth-alice.json" alice_file.write_text( json.dumps( @@ -107,7 +160,8 @@ def test_chat_config_uses_per_deployment_token_file(tmp_path): assert headers["Authorization"] == "Bearer alice-chat-token" -def test_chat_config_multi_deployment_token_isolation(tmp_path): +def test_chat_config_multi_deployment_token_isolation(tmp_path, monkeypatch): + monkeypatch.setenv("XAI_OAUTH_TOKEN_DIR", str(tmp_path)) alice = tmp_path / "auth-alice.json" alice.write_text( json.dumps( @@ -156,7 +210,8 @@ def test_chat_config_multi_deployment_token_isolation(tmp_path): assert headers_bob["Authorization"] == "Bearer bob-token" -def test_responses_config_uses_per_deployment_token_file(tmp_path): +def test_responses_config_uses_per_deployment_token_file(tmp_path, monkeypatch): + monkeypatch.setenv("XAI_OAUTH_TOKEN_DIR", str(tmp_path)) bob = tmp_path / "auth-bob.json" bob.write_text( json.dumps( @@ -180,28 +235,14 @@ def test_responses_config_uses_per_deployment_token_file(tmp_path): assert headers["Authorization"] == "Bearer bob-responses-token" -def test_proxy_cli_xai_oauth_login_with_account(monkeypatch, tmp_path): +def test_proxy_cli_xai_oauth_login_rejects_unsafe_account(monkeypatch, tmp_path): from litellm.proxy.proxy_cli import run_server - captured: dict[str, str | None] = {} - - class FakeAuthenticator: - def __init__(self, http_client=None, auth_file=None, token_dir=None): - captured["auth_file"] = auth_file - self.auth_file = auth_file or "/tmp/xai-oauth-auth.json" - - def login(self): - return {"expires_at": 1234567890} - - monkeypatch.setattr("litellm.llms.xai.oauth.XAIOAuthAuthenticator", FakeAuthenticator) monkeypatch.setenv("XAI_OAUTH_TOKEN_DIR", str(tmp_path)) + result = CliRunner().invoke(run_server, ["xai-oauth", "login", "../alice"]) - result = CliRunner().invoke(run_server, ["xai-oauth", "login", "alice"]) - - assert result.exit_code == 0 - expected = os.path.join(str(tmp_path), "auth-alice.json") - assert captured["auth_file"] == expected - assert expected in result.output + assert result.exit_code != 0 + assert result.exception is not None def test_spending_limit_403_maps_to_rate_limit_error(): @@ -222,3 +263,25 @@ def test_spending_limit_403_maps_to_rate_limit_error(): exception_provider="XaiException", extra_information="", ) + + +def test_spending_limit_403_on_non_xai_does_not_map_to_rate_limit_error(): + class FakeHTTPError(Exception): + def __init__(self): + self.status_code = 403 + self.message = "personal-team-blocked:spending-limit" + self.response = None + super().__init__(self.message) + + with pytest.raises(litellm.APIError) as exc_info: + _map_openai_exception( + model="gpt-4", + original_exception=FakeHTTPError(), + custom_llm_provider="openai", + error_str="personal-team-blocked:spending-limit", + exception_type="APIStatusError", + exception_provider="OpenAIException", + extra_information="", + ) + assert not isinstance(exc_info.value, litellm.RateLimitError) + assert exc_info.value.status_code == 403 diff --git a/tests/unit/proxy/auth/test_auth_utils.py b/tests/unit/proxy/auth/test_auth_utils.py index 8612351eda6..ac7d4fa56a1 100644 --- a/tests/unit/proxy/auth/test_auth_utils.py +++ b/tests/unit/proxy/auth/test_auth_utils.py @@ -5374,3 +5374,54 @@ def test_chatgpt_model_configuration_reaches_role_authorization( model="", route=inference_route, ) + + +class TestIsRequestBodySafeBlocksXaiOauthTokenFile: + """A caller must not select another local xAI OAuth account. Router kwargs + override deployment params, so ``xai_oauth_token_file`` in the request body + would authenticate as any token file readable on the proxy host. + """ + + def test_xai_oauth_token_file_is_in_banned_set(self): + from litellm.proxy.auth.auth_utils import _BANNED_REQUEST_BODY_PARAMS + + assert "xai_oauth_token_file" in set(_BANNED_REQUEST_BODY_PARAMS) + + def test_xai_oauth_token_file_in_request_body_is_rejected(self): + with pytest.raises(ValueError, match="xai_oauth_token_file"): + is_request_body_safe( + request_body={ + "model": "grok-4", + "xai_oauth_token_file": "/etc/passwd", + }, + general_settings={}, + llm_router=None, + model="grok-4", + ) + + def test_xai_oauth_token_file_under_extra_body_is_rejected(self): + with pytest.raises(ValueError, match="xai_oauth_token_file"): + is_request_body_safe( + request_body={ + "model": "grok-4", + "extra_body": {"xai_oauth_token_file": "/etc/passwd"}, + }, + general_settings={}, + llm_router=None, + model="grok-4", + ) + + def test_xai_oauth_token_file_allowed_under_proxy_wide_opt_in(self): + assert ( + is_request_body_safe( + request_body={ + "model": "grok-4", + "xai_oauth_token_file": "auth-alice.json", + }, + general_settings={"allow_client_side_credentials": True}, + llm_router=None, + model="grok-4", + ) + is True + ) + diff --git a/tests/unit/proxy/auth/test_banned_params_extra_body.py b/tests/unit/proxy/auth/test_banned_params_extra_body.py index e87b206a40a..6c621b03cba 100644 --- a/tests/unit/proxy/auth/test_banned_params_extra_body.py +++ b/tests/unit/proxy/auth/test_banned_params_extra_body.py @@ -28,6 +28,7 @@ from litellm.proxy.auth.auth_utils import is_request_body_safe # noqa: E402 "base_url", "vertex_credentials", "azure_ad_token", + "xai_oauth_token_file", ], ) def test_banned_param_under_extra_body_is_rejected(banned_param): From ff11218f2050c2b24c664e683107273f2a8c0ef7 Mon Sep 17 00:00:00 2001 From: hx <1367557521@qq.com> Date: Sun, 27 Sep 2026 18:07:43 +0800 Subject: [PATCH 03/15] fix(xai): reject xai_oauth_token_file in request bodies regardless of client-side opt-ins The token file picks which OAuth account stored on the proxy host signs the request, so allow_client_side_credentials and configurable_clientside_auth_params must not hand it to callers. Only deployment config may set it --- .../exception_mapping_utils.py | 23 -------- litellm/proxy/auth/auth_utils.py | 16 +++-- tests/unit/proxy/auth/test_auth_utils.py | 59 +++++++++++-------- 3 files changed, 45 insertions(+), 53 deletions(-) diff --git a/litellm/litellm_core_utils/exception_mapping_utils.py b/litellm/litellm_core_utils/exception_mapping_utils.py index 597f414747f..85c232ad3f4 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -514,29 +514,6 @@ def _map_openai_exception( response=response, litellm_debug_info=extra_information, ) - elif original_exception.status_code == 403: - from litellm.llms.xai.oauth import is_xai_spending_limit_error - - if is_xai_spending_limit_error( - custom_llm_provider=custom_llm_provider, - status_code=original_exception.status_code, - error_str=error_str, - ): - raise RateLimitError( - message=f"RateLimitError: {exception_provider} - {message}", - model=model, - llm_provider=custom_llm_provider, - response=getattr(original_exception, "response", None), - litellm_debug_info=extra_information, - ) - raise APIError( - status_code=original_exception.status_code, - message=f"APIError: {exception_provider} - {message}", - llm_provider=custom_llm_provider, - model=model, - request=getattr(original_exception, "request", None), - litellm_debug_info=extra_information, - ) elif original_exception.status_code == 404: raise NotFoundError( message=f"NotFoundError: {exception_provider} - {message}", diff --git a/litellm/proxy/auth/auth_utils.py b/litellm/proxy/auth/auth_utils.py index 05b80c380dd..9a03dd1f7ad 100644 --- a/litellm/proxy/auth/auth_utils.py +++ b/litellm/proxy/auth/auth_utils.py @@ -393,6 +393,10 @@ def _build_banned_observability_params() -> frozenset[str]: ) +# Credential selectors that name an account stored on the proxy host, so no +# client-side opt-in can hand them to a caller. Only the deployment config sets them. +_OPERATOR_ONLY_REQUEST_BODY_PARAMS: Final[tuple[str, ...]] = ("xai_oauth_token_file",) + _BANNED_REQUEST_BODY_PARAMS: Final[tuple[str, ...]] = ( "api_base", "base_url", @@ -417,11 +421,7 @@ _BANNED_REQUEST_BODY_PARAMS: Final[tuple[str, ...]] = ( # caller-supplied value is the same exfil shape as # ``aws_web_identity_token`` on the Bedrock path. "azure_ad_token", - # xAI SuperGrok OAuth token file. The xAI transformer reads - # ``xai_oauth_token_file`` and authenticates as that local account, - # so a caller-supplied path is the same credential-selector shape as - # ``aws_profile_name`` on the Bedrock path. - "xai_oauth_token_file", + *_OPERATOR_ONLY_REQUEST_BODY_PARAMS, # Endpoint-targeting fields that retarget the outbound request or # an observability callback. An attacker-controlled value either # exfiltrates the request payload (incl. messages + admin-set @@ -506,6 +506,12 @@ def _check_banned_params( reject_server_owned_wif_params(wif_body) if not manages_deployments: reject_federated_credential_reference(body) + operator_only: Final = next((param for param in _OPERATOR_ONLY_REQUEST_BODY_PARAMS if param in body), None) + if operator_only is not None: + raise ValueError( + f"Rejected Request: {operator_only} is not allowed in request body. " + "Set it on the deployment's litellm_params in your proxy config.yaml instead." + ) for param in _BANNED_REQUEST_BODY_PARAMS: if param not in body: continue diff --git a/tests/unit/proxy/auth/test_auth_utils.py b/tests/unit/proxy/auth/test_auth_utils.py index ac7d4fa56a1..695aeb9b678 100644 --- a/tests/unit/proxy/auth/test_auth_utils.py +++ b/tests/unit/proxy/auth/test_auth_utils.py @@ -5377,47 +5377,56 @@ def test_chatgpt_model_configuration_reaches_role_authorization( class TestIsRequestBodySafeBlocksXaiOauthTokenFile: - """A caller must not select another local xAI OAuth account. Router kwargs - override deployment params, so ``xai_oauth_token_file`` in the request body - would authenticate as any token file readable on the proxy host. + """``xai_oauth_token_file`` picks which OAuth account stored on the proxy host signs the + request, and router kwargs override deployment params, so no client-side opt-in may unlock it """ - def test_xai_oauth_token_file_is_in_banned_set(self): - from litellm.proxy.auth.auth_utils import _BANNED_REQUEST_BODY_PARAMS - - assert "xai_oauth_token_file" in set(_BANNED_REQUEST_BODY_PARAMS) - - def test_xai_oauth_token_file_in_request_body_is_rejected(self): + @pytest.mark.parametrize( + "request_body", + [ + {"model": "grok-4", "xai_oauth_token_file": "auth-bob.json"}, + {"model": "grok-4", "extra_body": {"xai_oauth_token_file": "auth-bob.json"}}, + {"model": "grok-4", "metadata": {"xai_oauth_token_file": "auth-bob.json"}}, + {"model": "grok-4", "fallbacks": [{"model": "grok-4", "xai_oauth_token_file": "auth-bob.json"}]}, + ], + ) + def test_rejected_even_under_proxy_wide_opt_in(self, request_body): with pytest.raises(ValueError, match="xai_oauth_token_file"): is_request_body_safe( - request_body={ - "model": "grok-4", - "xai_oauth_token_file": "/etc/passwd", - }, - general_settings={}, + request_body=request_body, + general_settings={"allow_client_side_credentials": True}, llm_router=None, model="grok-4", ) - def test_xai_oauth_token_file_under_extra_body_is_rejected(self): + def test_rejected_even_when_deployment_lists_it_as_clientside_configurable(self): + from litellm import Router + + router = Router( + model_list=[ + { + "model_name": "grok-4", + "litellm_params": { + "model": "xai/grok-4", + "use_xai_oauth": True, + "xai_oauth_token_file": "auth-alice.json", + "configurable_clientside_auth_params": ["xai_oauth_token_file"], + }, + } + ] + ) with pytest.raises(ValueError, match="xai_oauth_token_file"): is_request_body_safe( - request_body={ - "model": "grok-4", - "extra_body": {"xai_oauth_token_file": "/etc/passwd"}, - }, + request_body={"model": "grok-4", "xai_oauth_token_file": "auth-bob.json"}, general_settings={}, - llm_router=None, + llm_router=router, model="grok-4", ) - def test_xai_oauth_token_file_allowed_under_proxy_wide_opt_in(self): + def test_other_banned_params_still_honor_proxy_wide_opt_in(self): assert ( is_request_body_safe( - request_body={ - "model": "grok-4", - "xai_oauth_token_file": "auth-alice.json", - }, + request_body={"model": "grok-4", "api_base": "https://example.invalid/v1"}, general_settings={"allow_client_side_credentials": True}, llm_router=None, model="grok-4", From 71febf16a12eda95c4eba58de09d56a42d9f990d Mon Sep 17 00:00:00 2001 From: hx <1367557521@qq.com> Date: Sun, 27 Sep 2026 18:07:43 +0800 Subject: [PATCH 04/15] fix(xai): map spending-limit 403s in the xAI adapter and tighten token file handling Move the spending-limit to rate-limit mapping out of the shared exception mapper into XAIChatConfig and XAIResponsesAPIConfig get_error_class. Keep the operator's XAI_OAUTH_AUTH_FILE behaviour, reject symlinks that escape the token dir, report a usage error for unsafe CLI account names, and cover per-account router failover --- litellm/llms/xai/chat/transformation.py | 22 +- litellm/llms/xai/common_utils.py | 9 + litellm/llms/xai/oauth.py | 18 +- litellm/llms/xai/responses/transformation.py | 19 +- litellm/proxy/proxy_cli.py | 19 +- tests/unit/llms/xai/test_xai_oauth.py | 4 +- .../llms/xai/test_xai_oauth_multi_account.py | 414 ++++++++---------- tests/unit/types/test_litellm_params.py | 1 + 8 files changed, 243 insertions(+), 263 deletions(-) diff --git a/litellm/llms/xai/chat/transformation.py b/litellm/llms/xai/chat/transformation.py index 89a4c406f10..3898d482620 100644 --- a/litellm/llms/xai/chat/transformation.py +++ b/litellm/llms/xai/chat/transformation.py @@ -13,7 +13,8 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import ( filter_value_from_dict, strip_name_from_messages, ) -from litellm.llms.xai.common_utils import XAIModelInfo, xai_reported_cost_in_usd +from litellm.llms.base_llm.chat.transformation import BaseLLMException +from litellm.llms.xai.common_utils import XAIModelInfo, xai_error_status_code, xai_reported_cost_in_usd from litellm.llms.xai.cost_calculator import ( apply_server_side_tool_usage_details_to_usage, ) @@ -75,14 +76,14 @@ class XAIChatConfig(OpenAIGPTConfig): XAIOAuthAuthenticator, XAIOAuthError, should_use_xai_oauth, + xai_oauth_token_file, ) dynamic_api_key: Final = XAIModelInfo.get_api_key(api_key) if should_use_xai_oauth(litellm_params) and not dynamic_api_key: - raw_token_file: Final = (litellm_params or {}).get("xai_oauth_token_file") - token_file: Final = raw_token_file if isinstance(raw_token_file, str) else None try: - headers["Authorization"] = f"Bearer {XAIOAuthAuthenticator(auth_file=token_file).get_access_token()}" + authenticator: Final = XAIOAuthAuthenticator(auth_file=xai_oauth_token_file(litellm_params)) + headers["Authorization"] = f"Bearer {authenticator.get_access_token()}" except XAIOAuthError as exc: raise AuthenticationError( model=model, @@ -116,9 +117,7 @@ class XAIChatConfig(OpenAIGPTConfig): dynamic_api_key: Final = XAIModelInfo.get_api_key(api_key) if should_use_xai_oauth(litellm_params) and not dynamic_api_key: - raw_token_file: Final = (litellm_params or {}).get("xai_oauth_token_file") - token_file: Final = raw_token_file if isinstance(raw_token_file, str) else None - api_base = XAIOAuthAuthenticator(auth_file=token_file).get_api_base() + api_base = XAIOAuthAuthenticator().get_api_base() return super().get_complete_url( api_base=api_base, @@ -129,6 +128,15 @@ class XAIChatConfig(OpenAIGPTConfig): stream=stream, ) + def get_error_class( + self, error_message: str, status_code: int, headers: dict[str, str] | httpx.Headers + ) -> BaseLLMException: + return super().get_error_class( + error_message=error_message, + status_code=xai_error_status_code(status_code, error_message), + headers=headers, + ) + def get_supported_openai_params(self, model: str) -> list: base_openai_params: Final = [ "logit_bias", diff --git a/litellm/llms/xai/common_utils.py b/litellm/llms/xai/common_utils.py index 06e3f32a767..2d2d473fe22 100644 --- a/litellm/llms/xai/common_utils.py +++ b/litellm/llms/xai/common_utils.py @@ -1,4 +1,5 @@ from collections.abc import Iterable, Mapping +from http import HTTPStatus from typing import Final import httpx @@ -15,6 +16,14 @@ USD_TICKS_PER_DOLLAR: Final = 10_000_000_000 _JSON_OBJECT: Final = TypeAdapter(Mapping[str, object], config=ConfigDict(hide_input_in_errors=True)) _JSON_VALUES: Final = TypeAdapter(Iterable[object], config=ConfigDict(hide_input_in_errors=True)) _MODEL_ID: Final = TypeAdapter(str, config=ConfigDict(strict=True, hide_input_in_errors=True)) +XAI_SPENDING_LIMIT_ERROR_MARKER: Final = "spending-limit" + + +def xai_error_status_code(status_code: int, error_message: str) -> int: + """xAI rejects requests past an account's spending limit with a 403; report it as a 429 so routers fail over""" + if status_code == HTTPStatus.FORBIDDEN and XAI_SPENDING_LIMIT_ERROR_MARKER in error_message: + return HTTPStatus.TOO_MANY_REQUESTS.value + return status_code def xai_reported_cost_in_usd(cost_in_usd_ticks: object) -> float | None: diff --git a/litellm/llms/xai/oauth.py b/litellm/llms/xai/oauth.py index 019c98c8450..c719c1cf97c 100644 --- a/litellm/llms/xai/oauth.py +++ b/litellm/llms/xai/oauth.py @@ -82,23 +82,23 @@ def _default_xai_oauth_token_dir() -> str: def resolve_xai_oauth_auth_file(auth_file: str | None, token_dir: str) -> str: - requested: Final = auth_file or get_secret_str("XAI_OAUTH_AUTH_FILE") or "auth.json" - candidate: Final = requested if os.path.isabs(requested) else os.path.join(token_dir, requested) - resolved: Final = os.path.realpath(candidate) - allowed: Final = os.path.realpath(token_dir) - if resolved == allowed or resolved.startswith(allowed + os.sep): + if not auth_file: + return os.path.join(token_dir, get_secret_str("XAI_OAUTH_AUTH_FILE") or "auth.json") + resolved: Final = os.path.realpath(os.path.join(token_dir, auth_file)) + if resolved.startswith(os.path.realpath(token_dir) + os.sep): return resolved raise XAIOAuthError("xAI OAuth auth file must stay inside the token directory") -def oauth_auth_file_for_account(account: str, token_dir: str) -> str: +def oauth_auth_file_for_account(account: str) -> str: if not _XAI_OAUTH_ACCOUNT_NAME_RE.fullmatch(account): raise ValueError("xAI OAuth account must match ^[A-Za-z0-9_-]+$") - return resolve_xai_oauth_auth_file(f"auth-{account}.json", token_dir) + return f"auth-{account}.json" -def is_xai_spending_limit_error(*, custom_llm_provider: str, status_code: int, error_str: str) -> bool: - return custom_llm_provider == "xai" and status_code == 403 and "spending-limit" in error_str +def xai_oauth_token_file(litellm_params: Mapping[str, object] | None) -> str | None: + token_file: Final = (litellm_params or {}).get("xai_oauth_token_file") + return token_file if isinstance(token_file, str) else None class _CallbackHandler(BaseHTTPRequestHandler): diff --git a/litellm/llms/xai/responses/transformation.py b/litellm/llms/xai/responses/transformation.py index 4498148f965..02658098cc2 100644 --- a/litellm/llms/xai/responses/transformation.py +++ b/litellm/llms/xai/responses/transformation.py @@ -10,12 +10,13 @@ from litellm._logging import verbose_logger from litellm.constants import XAI_API_BASE from litellm.exceptions import AuthenticationError from litellm.litellm_core_utils.url_utils import join_api_base +from litellm.llms.base_llm.chat.transformation import BaseLLMException from litellm.llms.base_llm.responses.transformation import ( STANDARD_MODE_WITHOUT_CONTEXT_REASONING_FIELD_VALUES, ReasoningFieldValues, ) from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig -from litellm.llms.xai.common_utils import XAIModelInfo, xai_reported_cost_in_usd +from litellm.llms.xai.common_utils import XAIModelInfo, xai_error_status_code, xai_reported_cost_in_usd from litellm.secret_managers.main import get_secret_str from litellm.types.llms.openai import ( ResponseAPIUsage, @@ -213,9 +214,8 @@ class XAIResponsesAPIConfig(OpenAIResponsesAPIConfig): ) if should_use_xai_oauth(litellm_params.model_dump()): - token_file: Final = litellm_params.xai_oauth_token_file try: - api_key = XAIOAuthAuthenticator(auth_file=token_file).get_access_token() + api_key = XAIOAuthAuthenticator(auth_file=litellm_params.xai_oauth_token_file).get_access_token() except XAIOAuthError as exc: raise AuthenticationError( model=model, @@ -251,14 +251,21 @@ class XAIResponsesAPIConfig(OpenAIResponsesAPIConfig): api_key: Final = XAIModelInfo.get_api_key(litellm_params.get("api_key"), legacy_generic_before_env=True) if should_use_xai_oauth(litellm_params) and not api_key: - raw_token_file: Final = litellm_params.get("xai_oauth_token_file") - token_file: Final = raw_token_file if isinstance(raw_token_file, str) else None - api_base = XAIOAuthAuthenticator(auth_file=token_file).get_api_base() + api_base = XAIOAuthAuthenticator().get_api_base() else: api_base = api_base or litellm.api_base or get_secret_str("XAI_API_BASE") or XAI_API_BASE return join_api_base(api_base, "responses") + def get_error_class( + self, error_message: str, status_code: int, headers: dict[str, str] | httpx.Headers + ) -> BaseLLMException: + return super().get_error_class( + error_message=error_message, + status_code=xai_error_status_code(status_code, error_message), + headers=headers, + ) + def transform_response_api_response( self, model: str, diff --git a/litellm/proxy/proxy_cli.py b/litellm/proxy/proxy_cli.py index 7b4c6933939..d3188e7808e 100644 --- a/litellm/proxy/proxy_cli.py +++ b/litellm/proxy/proxy_cli.py @@ -1209,7 +1209,7 @@ class ProxyInitializationHelpers: envvar="PROMETHEUS_METRICS_PORT", ) def run_server( - cli_args, + cli_args: tuple[str, ...], host, port, api_base, @@ -1263,22 +1263,17 @@ def run_server( prometheus_metrics_port: int | None, ): if cli_args: - if len(cli_args) >= 2 and cli_args[0] == "xai-oauth" and cli_args[1] == "login": + if cli_args[:2] == ("xai-oauth", "login") and len(cli_args) <= 3: from litellm.llms.xai.oauth import ( XAIOAuthAuthenticator, oauth_auth_file_for_account, ) - from litellm.secret_managers.main import get_secret_str - account: Final = cli_args[2] if len(cli_args) >= 3 else None - token_dir: Final = get_secret_str("XAI_OAUTH_TOKEN_DIR") or os.path.expanduser( - "~/.config/litellm/xai_oauth" - ) - authenticator: Final = ( - XAIOAuthAuthenticator(auth_file=oauth_auth_file_for_account(account, token_dir), token_dir=token_dir) - if account - else XAIOAuthAuthenticator() - ) + try: + auth_file: Final = oauth_auth_file_for_account(cli_args[2]) if len(cli_args) == 3 else None + except ValueError as exc: + raise click.UsageError(str(exc)) from exc + authenticator: Final = XAIOAuthAuthenticator(auth_file=auth_file) auth_data: Final = authenticator.login() click.echo(f"xAI OAuth login successful. Credentials saved to {authenticator.auth_file}.") if auth_data.get("expires_at"): diff --git a/tests/unit/llms/xai/test_xai_oauth.py b/tests/unit/llms/xai/test_xai_oauth.py index 3fc4c35052e..0456c993832 100644 --- a/tests/unit/llms/xai/test_xai_oauth.py +++ b/tests/unit/llms/xai/test_xai_oauth.py @@ -783,7 +783,8 @@ def test_proxy_cli_xai_oauth_login_uses_single_authenticator(monkeypatch): class FakeAuthenticator: auth_file = "/tmp/xai-oauth-auth.json" - def __init__(self): + def __init__(self, auth_file=None): + self.requested_auth_file = auth_file instances.append(self) def login(self): @@ -797,5 +798,6 @@ def test_proxy_cli_xai_oauth_login_uses_single_authenticator(monkeypatch): assert result.exit_code == 0 assert len(instances) == 1 + assert instances[0].requested_auth_file is None assert "Credentials saved to /tmp/xai-oauth-auth.json" in result.output assert "Access token expires at 1234567890" in result.output diff --git a/tests/unit/llms/xai/test_xai_oauth_multi_account.py b/tests/unit/llms/xai/test_xai_oauth_multi_account.py index 254b6cd151d..c611a98d949 100644 --- a/tests/unit/llms/xai/test_xai_oauth_multi_account.py +++ b/tests/unit/llms/xai/test_xai_oauth_multi_account.py @@ -1,287 +1,245 @@ import json import os -import time +from pathlib import Path +import httpx import pytest +import respx from click.testing import CliRunner import litellm -from litellm.litellm_core_utils.exception_mapping_utils import _map_openai_exception +from litellm import Router +from litellm.llms.base_llm.chat.transformation import BaseLLMException from litellm.llms.xai.chat.transformation import XAIChatConfig -from litellm.llms.xai.oauth import XAIOAuthAuthenticator, XAIOAuthError +from litellm.llms.xai.oauth import XAIOAuthAuthenticator, XAIOAuthError, oauth_auth_file_for_account from litellm.llms.xai.responses.transformation import XAIResponsesAPIConfig from litellm.types.router import GenericLiteLLMParams +FAR_FUTURE_EXPIRY = 4_102_444_800 +SPENDING_LIMIT_BODY = '{"code":"personal-team-blocked:spending-limit","error":"spending limit reached"}' -def test_authenticator_accepts_explicit_absolute_auth_file(tmp_path): - custom = tmp_path / "custom-dir" / "alice.json" - custom.parent.mkdir(parents=True) - custom.write_text( - json.dumps( - { - "access_token": "alice-token", - "refresh_token": "refresh-token", - "expires_at": time.time() + 3600, - } - ) + +@pytest.fixture(autouse=True) +def _oauth_only(monkeypatch): + monkeypatch.delenv("XAI_API_KEY", raising=False) + monkeypatch.delenv("XAI_OAUTH_AUTH_FILE", raising=False) + monkeypatch.setattr(litellm, "xai_key", None) + monkeypatch.setattr(litellm, "api_key", None) + + +@pytest.fixture +def token_dir(tmp_path, monkeypatch): + directory = tmp_path / "xai_oauth" + directory.mkdir() + monkeypatch.setenv("XAI_OAUTH_TOKEN_DIR", str(directory)) + return directory + + +def _write_token(path: Path, access_token: str) -> Path: + path.write_text( + json.dumps({"access_token": access_token, "refresh_token": "refresh", "expires_at": FAR_FUTURE_EXPIRY}) + ) + return path + + +def _chat_headers(token_file: str) -> dict: + return XAIChatConfig().validate_environment( + headers={}, + model="grok-4", + messages=[], + optional_params={}, + litellm_params={"use_xai_oauth": True, "xai_oauth_token_file": token_file}, + api_key=None, ) - auth = XAIOAuthAuthenticator(auth_file=str(custom), token_dir=str(custom.parent)) - assert auth.auth_file == os.path.realpath(str(custom)) +def test_absolute_auth_file_inside_token_dir_is_read(token_dir): + alice = _write_token(token_dir / "alice.json", "alice-token") + + auth = XAIOAuthAuthenticator(auth_file=str(alice)) + + assert auth.auth_file == os.path.realpath(alice) assert auth.get_access_token() == "alice-token" -def test_authenticator_relative_auth_file_joins_token_dir(tmp_path, monkeypatch): - token_dir = tmp_path / "xai_oauth" - token_dir.mkdir() - (token_dir / "auth-bob.json").write_text( - json.dumps( - { - "access_token": "bob-token", - "refresh_token": "refresh-token", - "expires_at": time.time() + 3600, - } - ) - ) - monkeypatch.setenv("XAI_OAUTH_TOKEN_DIR", str(token_dir)) +def test_relative_auth_file_resolves_inside_token_dir(token_dir): + _write_token(token_dir / "auth-bob.json", "bob-token") - auth = XAIOAuthAuthenticator(auth_file="auth-bob.json") - - assert auth.auth_file == os.path.realpath(str(token_dir / "auth-bob.json")) - assert auth.get_access_token() == "bob-token" + assert XAIOAuthAuthenticator(auth_file="auth-bob.json").get_access_token() == "bob-token" -def test_authenticator_rejects_relative_path_escaping_token_dir(tmp_path, monkeypatch): - token_dir = tmp_path / "xai_oauth" - token_dir.mkdir() - monkeypatch.setenv("XAI_OAUTH_TOKEN_DIR", str(token_dir)) +@pytest.mark.parametrize("auth_file", ["../outside.json", ".", "sub/../../outside.json"]) +def test_relative_auth_file_escaping_token_dir_is_rejected(token_dir, auth_file): + _write_token(token_dir.parent / "outside.json", "outside-token") with pytest.raises(XAIOAuthError, match="token directory"): - XAIOAuthAuthenticator(auth_file="../secret.json") + XAIOAuthAuthenticator(auth_file=auth_file) -def test_authenticator_rejects_absolute_path_outside_token_dir(tmp_path, monkeypatch): - token_dir = tmp_path / "xai_oauth" - token_dir.mkdir() - outside = tmp_path / "outside.json" - outside.write_text("{}") - monkeypatch.setenv("XAI_OAUTH_TOKEN_DIR", str(token_dir)) +def test_absolute_auth_file_outside_token_dir_is_rejected(token_dir): + outside = _write_token(token_dir.parent / "outside.json", "outside-token") with pytest.raises(XAIOAuthError, match="token directory"): XAIOAuthAuthenticator(auth_file=str(outside)) -def test_authenticator_rejects_dotdot_absolute_path_outside_token_dir(tmp_path, monkeypatch): - token_dir = tmp_path / "xai_oauth" - token_dir.mkdir() - monkeypatch.setenv("XAI_OAUTH_TOKEN_DIR", str(token_dir)) - escaped = str((token_dir / ".." / "outside.json").resolve()) +def test_symlink_inside_token_dir_pointing_outside_is_rejected(token_dir): + outside = _write_token(token_dir.parent / "outside.json", "outside-token") + (token_dir / "link.json").symlink_to(outside) with pytest.raises(XAIOAuthError, match="token directory"): - XAIOAuthAuthenticator(auth_file=escaped) + XAIOAuthAuthenticator(auth_file="link.json") -def test_oauth_auth_file_for_account_builds_path_inside_token_dir(tmp_path): - from litellm.llms.xai.oauth import oauth_auth_file_for_account - - assert oauth_auth_file_for_account("alice", str(tmp_path)) == os.path.realpath( - os.path.join(str(tmp_path), "auth-alice.json") - ) - assert oauth_auth_file_for_account("Bob_1-2", str(tmp_path)) == os.path.realpath( - os.path.join(str(tmp_path), "auth-Bob_1-2.json") - ) - - -@pytest.mark.parametrize( - "account", - ["", ".", "..", "foo/bar", "../alice", "alice.json", "foo\\bar", "alice/../bob"], -) -def test_oauth_auth_file_for_account_rejects_unsafe_names(account, tmp_path): - from litellm.llms.xai.oauth import oauth_auth_file_for_account - - with pytest.raises(ValueError, match="account"): - oauth_auth_file_for_account(account, str(tmp_path)) - - -def test_authenticator_explicit_auth_file_overrides_env(tmp_path, monkeypatch): - env_file = tmp_path / "env.json" - env_file.write_text( - json.dumps( - { - "access_token": "env-token", - "refresh_token": "r", - "expires_at": time.time() + 3600, - } - ) - ) - explicit_file = tmp_path / "explicit.json" - explicit_file.write_text( - json.dumps( - { - "access_token": "explicit-token", - "refresh_token": "r", - "expires_at": time.time() + 3600, - } - ) - ) +def test_operator_env_auth_file_outside_token_dir_keeps_working(token_dir, monkeypatch): + env_file = _write_token(token_dir.parent / "env.json", "env-token") monkeypatch.setenv("XAI_OAUTH_AUTH_FILE", str(env_file)) - auth = XAIOAuthAuthenticator(auth_file=str(explicit_file), token_dir=str(tmp_path)) - - assert auth.get_access_token() == "explicit-token" + assert XAIOAuthAuthenticator().get_access_token() == "env-token" -def test_chat_config_uses_per_deployment_token_file(tmp_path, monkeypatch): - monkeypatch.setenv("XAI_OAUTH_TOKEN_DIR", str(tmp_path)) - alice_file = tmp_path / "auth-alice.json" - alice_file.write_text( - json.dumps( - { - "access_token": "alice-chat-token", - "refresh_token": "refresh-token", - "expires_at": time.time() + 3600, - } - ) - ) +def test_explicit_auth_file_wins_over_env_auth_file(token_dir, monkeypatch): + monkeypatch.setenv("XAI_OAUTH_AUTH_FILE", str(_write_token(token_dir / "env.json", "env-token"))) + explicit = _write_token(token_dir / "explicit.json", "explicit-token") - headers = XAIChatConfig().validate_environment( - headers={}, - model="grok-4", - messages=[], - optional_params={}, - litellm_params={ - "use_xai_oauth": True, - "xai_oauth_token_file": str(alice_file), - }, - api_key=None, - ) - - assert headers["Authorization"] == "Bearer alice-chat-token" + assert XAIOAuthAuthenticator(auth_file=str(explicit)).get_access_token() == "explicit-token" -def test_chat_config_multi_deployment_token_isolation(tmp_path, monkeypatch): - monkeypatch.setenv("XAI_OAUTH_TOKEN_DIR", str(tmp_path)) - alice = tmp_path / "auth-alice.json" - alice.write_text( - json.dumps( - { - "access_token": "alice-token", - "refresh_token": "r", - "expires_at": time.time() + 3600, - } - ) - ) - bob = tmp_path / "auth-bob.json" - bob.write_text( - json.dumps( - { - "access_token": "bob-token", - "refresh_token": "r", - "expires_at": time.time() + 3600, - } - ) - ) +@pytest.mark.parametrize("account", ["alice", "Bob_1-2"]) +def test_account_login_file_lands_in_token_dir(token_dir, account): + auth = XAIOAuthAuthenticator(auth_file=oauth_auth_file_for_account(account)) - headers_alice = XAIChatConfig().validate_environment( - headers={}, - model="grok-4", - messages=[], - optional_params={}, - litellm_params={ - "use_xai_oauth": True, - "xai_oauth_token_file": str(alice), - }, - api_key=None, - ) - headers_bob = XAIChatConfig().validate_environment( - headers={}, - model="grok-4", - messages=[], - optional_params={}, - litellm_params={ - "use_xai_oauth": True, - "xai_oauth_token_file": str(bob), - }, - api_key=None, - ) - - assert headers_alice["Authorization"] == "Bearer alice-token" - assert headers_bob["Authorization"] == "Bearer bob-token" + assert auth.auth_file == os.path.realpath(token_dir / f"auth-{account}.json") -def test_responses_config_uses_per_deployment_token_file(tmp_path, monkeypatch): - monkeypatch.setenv("XAI_OAUTH_TOKEN_DIR", str(tmp_path)) - bob = tmp_path / "auth-bob.json" - bob.write_text( - json.dumps( - { - "access_token": "bob-responses-token", - "refresh_token": "r", - "expires_at": time.time() + 3600, - } - ) - ) +@pytest.mark.parametrize("account", ["", ".", "..", "foo/bar", "../alice", "alice.json", "foo\\bar", "alice/../bob"]) +def test_unsafe_account_names_are_rejected(account): + with pytest.raises(ValueError, match="account"): + oauth_auth_file_for_account(account) + + +def test_cli_login_rejects_unsafe_account_before_starting_oauth(token_dir): + from litellm.proxy.proxy_cli import run_server + + result = CliRunner().invoke(run_server, ["xai-oauth", "login", "../alice"]) + + assert result.exit_code == 2 + assert "xAI OAuth account must match" in result.output + assert list(token_dir.iterdir()) == [] + + +def test_cli_login_rejects_extra_arguments(token_dir): + from litellm.proxy.proxy_cli import run_server + + result = CliRunner().invoke(run_server, ["xai-oauth", "login", "alice", "bob"]) + + assert result.exit_code == 2 + assert "Unknown command" in result.output + + +def test_chat_deployments_authenticate_with_their_own_token_files(token_dir): + alice = _write_token(token_dir / "auth-alice.json", "alice-token") + bob = _write_token(token_dir / "auth-bob.json", "bob-token") + + assert _chat_headers(str(alice))["Authorization"] == "Bearer alice-token" + assert _chat_headers(str(bob))["Authorization"] == "Bearer bob-token" + + +def test_chat_token_file_outside_token_dir_raises_authentication_error(token_dir): + outside = _write_token(token_dir.parent / "outside.json", "outside-token") + + with pytest.raises(litellm.AuthenticationError, match="token directory"): + _chat_headers(str(outside)) + + +def test_responses_deployment_authenticates_with_its_token_file(token_dir): + bob = _write_token(token_dir / "auth-bob.json", "bob-responses-token") headers = XAIResponsesAPIConfig().validate_environment( headers={}, model="grok-4", - litellm_params=GenericLiteLLMParams( - use_xai_oauth=True, - xai_oauth_token_file=str(bob), - ), + litellm_params=GenericLiteLLMParams(use_xai_oauth=True, xai_oauth_token_file=str(bob)), ) assert headers["Authorization"] == "Bearer bob-responses-token" -def test_proxy_cli_xai_oauth_login_rejects_unsafe_account(monkeypatch, tmp_path): - from litellm.proxy.proxy_cli import run_server +@pytest.mark.parametrize("config", [XAIChatConfig(), XAIResponsesAPIConfig()]) +@pytest.mark.parametrize( + ("status_code", "message", "expected_status"), + [ + (403, SPENDING_LIMIT_BODY, 429), + (403, '{"error":"model access denied"}', 403), + (400, SPENDING_LIMIT_BODY, 400), + ], +) +def test_error_class_reports_spending_limit_403_as_rate_limit(config, status_code, message, expected_status): + try: + error = config.get_error_class(error_message=message, status_code=status_code, headers={}) + except BaseLLMException as raised: + error = raised - monkeypatch.setenv("XAI_OAUTH_TOKEN_DIR", str(tmp_path)) - result = CliRunner().invoke(run_server, ["xai-oauth", "login", "../alice"]) - - assert result.exit_code != 0 - assert result.exception is not None + assert error.status_code == expected_status + assert error.message == message -def test_spending_limit_403_maps_to_rate_limit_error(): - class FakeHTTPError(Exception): - def __init__(self): - self.status_code = 403 - self.message = "personal-team-blocked:spending-limit" - self.response = None - super().__init__(self.message) - - with pytest.raises(litellm.RateLimitError): - _map_openai_exception( - model="grok-4", - original_exception=FakeHTTPError(), - custom_llm_provider="xai", - error_str="personal-team-blocked:spending-limit", - exception_type="APIStatusError", - exception_provider="XaiException", - extra_information="", - ) +def _chat_completion_body(content: str) -> dict: + return { + "id": "chatcmpl-test", + "object": "chat.completion", + "created": 0, + "model": "grok-4", + "choices": [{"index": 0, "message": {"role": "assistant", "content": content}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + } -def test_spending_limit_403_on_non_xai_does_not_map_to_rate_limit_error(): - class FakeHTTPError(Exception): - def __init__(self): - self.status_code = 403 - self.message = "personal-team-blocked:spending-limit" - self.response = None - super().__init__(self.message) +def _xai_by_account(request: httpx.Request) -> httpx.Response: + if request.headers["Authorization"] == "Bearer alice-token": + return httpx.Response(403, text=SPENDING_LIMIT_BODY) + return httpx.Response(200, json=_chat_completion_body(f"served with {request.headers['Authorization']}")) - with pytest.raises(litellm.APIError) as exc_info: - _map_openai_exception( - model="gpt-4", - original_exception=FakeHTTPError(), - custom_llm_provider="openai", - error_str="personal-team-blocked:spending-limit", - exception_type="APIStatusError", - exception_provider="OpenAIException", - extra_information="", - ) - assert not isinstance(exc_info.value, litellm.RateLimitError) - assert exc_info.value.status_code == 403 + +def test_spending_limit_403_surfaces_as_rate_limit_error(token_dir): + alice = _write_token(token_dir / "auth-alice.json", "alice-token") + + with respx.mock(assert_all_called=True) as router: + router.post("https://api.x.ai/v1/chat/completions").mock(side_effect=_xai_by_account) + with pytest.raises(litellm.RateLimitError, match="spending-limit"): + litellm.completion( + model="xai/grok-4", + messages=[{"role": "user", "content": "hi"}], + use_xai_oauth=True, + xai_oauth_token_file=str(alice), + ) + + +def test_router_fails_over_to_next_account_when_one_hits_its_spending_limit(token_dir): + alice = _write_token(token_dir / "auth-alice.json", "alice-token") + bob = _write_token(token_dir / "auth-bob.json", "bob-token") + llm_router = Router( + model_list=[ + { + "model_name": "grok", + "litellm_params": { + "model": "xai/grok-4", + "use_xai_oauth": True, + "xai_oauth_token_file": str(account_file), + "order": order, + }, + } + for order, account_file in ((1, alice), (2, bob)) + ], + num_retries=1, + retry_after=0, + ) + + with respx.mock(assert_all_called=True) as mock: + route = mock.post("https://api.x.ai/v1/chat/completions").mock(side_effect=_xai_by_account) + response = llm_router.completion(model="grok", messages=[{"role": "user", "content": "hi"}]) + + assert response.choices[0].message.content == "served with Bearer bob-token" + assert [call.request.headers["Authorization"] for call in route.calls] == [ + "Bearer alice-token", + "Bearer bob-token", + ] diff --git a/tests/unit/types/test_litellm_params.py b/tests/unit/types/test_litellm_params.py index 22eb9929e7f..eba0e5b9e3e 100644 --- a/tests/unit/types/test_litellm_params.py +++ b/tests/unit/types/test_litellm_params.py @@ -97,6 +97,7 @@ CONNECTION_NAMES: Final = ( "use_xai_oauth", "chatgpt_auth_file", "fireworks_forward_user_id", + "xai_oauth_token_file", "aws_batch_role_arn", "aws_bedrock_control_plane_endpoint", "s3_bucket_name", From 2948ac13e411a9330050efb96287b7ef358d6e85 Mon Sep 17 00:00:00 2001 From: hx <1367557521@qq.com> Date: Sun, 27 Sep 2026 18:07:44 +0800 Subject: [PATCH 05/15] chore(ui): regenerate schema.d.ts for xai_oauth_token_file --- litellm/types/router.py | 6 +++--- ui/litellm-dashboard/src/lib/http/schema.d.ts | 10 ++++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/litellm/types/router.py b/litellm/types/router.py index 17850ef6356..caa1b8e70a1 100644 --- a/litellm/types/router.py +++ b/litellm/types/router.py @@ -613,9 +613,9 @@ class GenericLiteLLMParams(CredentialLiteLLMParams, CustomPricingLiteLLMParams): xai_oauth_token_file: str | None = Field( default=None, description=( - "Per-deployment xAI OAuth token file path. When set with use_xai_oauth=True, " - "the request reads that account's OAuth credentials instead of the global " - "token file, enabling multi-account SuperGrok routing." + "Per-deployment xAI OAuth token file, relative to XAI_OAUTH_TOKEN_DIR or an absolute path inside it. " + "With use_xai_oauth=True the deployment authenticates as that account, enabling multi-account " + "SuperGrok routing. Deployment config only: the proxy rejects it in request bodies." ), ) model_config = ConfigDict(extra="allow", arbitrary_types_allowed=True) diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 30d61f181cb..984c8ebc7df 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -35972,6 +35972,11 @@ export interface components { vertex_project?: string | null; /** Watsonx Region Name */ watsonx_region_name?: string | null; + /** + * Xai Oauth Token File + * @description Per-deployment xAI OAuth token file, relative to XAI_OAUTH_TOKEN_DIR or an absolute path inside it. With use_xai_oauth=True the deployment authenticates as that account, enabling multi-account SuperGrok routing. Deployment config only: the proxy rejects it in request bodies. + */ + xai_oauth_token_file?: string | null; } & { [key: string]: unknown; }; @@ -51500,6 +51505,11 @@ export interface components { vertex_project?: string | null; /** Watsonx Region Name */ watsonx_region_name?: string | null; + /** + * Xai Oauth Token File + * @description Per-deployment xAI OAuth token file, relative to XAI_OAUTH_TOKEN_DIR or an absolute path inside it. With use_xai_oauth=True the deployment authenticates as that account, enabling multi-account SuperGrok routing. Deployment config only: the proxy rejects it in request bodies. + */ + xai_oauth_token_file?: string | null; } & { [key: string]: unknown; }; From 9f5f69308c755dfdc42b2ef9cb981e504793bc90 Mon Sep 17 00:00:00 2001 From: hx <1367557521@qq.com> Date: Sat, 3 Oct 2026 00:12:38 +0800 Subject: [PATCH 06/15] test(proxy): classify xai_oauth_token_file in the credential slot registry --- tests/unit/proxy/test_credential_slot_registry.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/proxy/test_credential_slot_registry.py b/tests/unit/proxy/test_credential_slot_registry.py index 6332038feb8..4d9e00d327f 100644 --- a/tests/unit/proxy/test_credential_slot_registry.py +++ b/tests/unit/proxy/test_credential_slot_registry.py @@ -122,6 +122,7 @@ DEPLOYMENT_PARAM_CLASSIFICATION: Final[Mapping[str, Classification]] = MappingPr "aws_secret_access_key": Secret("B4"), "aws_session_token": Unplanted(), "aws_web_identity_token": Unplanted(), + "xai_oauth_token_file": Unplanted(), "s3_access_key_id": Unplanted(), "s3_secret_access_key": Unplanted(), "s3_encryption_key_id": NotSecret("KMS key identifier, not key material"), From 9087ff5ba7a1aee418e74bb7c9efa98b52f43334 Mon Sep 17 00:00:00 2001 From: hx <1367557521@qq.com> Date: Sat, 3 Oct 2026 00:58:16 +0800 Subject: [PATCH 07/15] fix(responses): validate litellm params without unpacking untyped kwargs Adding xai_oauth_token_file to GenericLiteLLMParams adds one unknown argument at every GenericLiteLLMParams(**kwargs) call site. Building the params in responses() with model_validate, which behaves the same for a model without a custom __init__, keeps the basedpyright total below base. --- litellm/responses/main.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/litellm/responses/main.py b/litellm/responses/main.py index 9259103cb5c..71574a49d7b 100644 --- a/litellm/responses/main.py +++ b/litellm/responses/main.py @@ -1590,6 +1590,9 @@ def responses( ) local_vars["extra_headers"] = provider_headers + litellm_params: Final = GenericLiteLLMParams.model_validate( + {**cast("dict[str, object]", kwargs), "extra_headers": extra_headers} + ) affinity_api_base: Final = ( get_openai_credentials(api_base=litellm_params.api_base).api_base if custom_llm_provider == "openai" From 0b649689b40ae45814c5f98b372db40084719acf Mon Sep 17 00:00:00 2001 From: hx <1367557521@qq.com> Date: Mon, 5 Oct 2026 18:55:37 +0800 Subject: [PATCH 08/15] chore(ui): regenerate schema.d.ts against upstream main --- ui/litellm-dashboard/src/lib/http/schema.d.ts | 82 ------------------- 1 file changed, 82 deletions(-) diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 984c8ebc7df..2601cc73397 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -9247,24 +9247,6 @@ export interface paths { patch: operations["lens_request_lens__path__patch"]; trace?: never; }; - "/liteadmin/slack/connect/{token}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Connect Page */ - get: operations["connect_page_liteadmin_slack_connect__token__get"]; - put?: never; - /** Connect Account */ - post: operations["connect_account_liteadmin_slack_connect__token__post"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/litellm/.well-known/litellm-ui-config": { parameters: { query?: never; @@ -53173,8 +53155,6 @@ export interface operations { object_team_id?: string | null; /** @description Filter by token (key hash) present in before_value or updated_values JSON (PostgreSQL only) */ object_key_hash?: string | null; - /** @description Match a row whose id, object_id, changed_by, or changed_by_api_key equals this value */ - search?: string | null; /** @description Column to sort by (e.g. 'updated_at', 'action', 'table_name') */ sort_by?: string | null; /** @description Sort order ('asc' or 'desc') */ @@ -64646,68 +64626,6 @@ export interface operations { }; }; }; - connect_page_liteadmin_slack_connect__token__get: { - parameters: { - query?: never; - header?: never; - path: { - token: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "text/html": string; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; - }; - connect_account_liteadmin_slack_connect__token__post: { - parameters: { - query?: never; - header?: never; - path: { - token: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "text/html": string; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; - }; get_ui_config_litellm__well_known_litellm_ui_config_get: { parameters: { query?: never; From 87104f5bb4a85ccfd46662bdfaebf80895adf3e7 Mon Sep 17 00:00:00 2001 From: hx <1367557521@qq.com> Date: Mon, 5 Oct 2026 19:20:58 +0800 Subject: [PATCH 09/15] style: sort imports --- tests/unit/llms/xai/test_xai_oauth.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/unit/llms/xai/test_xai_oauth.py b/tests/unit/llms/xai/test_xai_oauth.py index 0456c993832..0ad9cedc00e 100644 --- a/tests/unit/llms/xai/test_xai_oauth.py +++ b/tests/unit/llms/xai/test_xai_oauth.py @@ -4,24 +4,24 @@ import json import os import threading import time -from urllib.parse import parse_qs, urlparse from unittest.mock import MagicMock +from urllib.parse import parse_qs, urlparse import httpx -import litellm import pytest from click.testing import CliRunner +import litellm import litellm.llms.xai.oauth as xai_oauth_module from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider +from litellm.llms.xai.chat.transformation import XAIChatConfig from litellm.llms.xai.oauth import ( XAI_OAUTH_CLIENT_ID, XAI_OAUTH_SCOPE, - XAIOAuthError, XAIOAuthAuthenticator, + XAIOAuthError, XAIOAuthLoginRequiredError, ) -from litellm.llms.xai.chat.transformation import XAIChatConfig from litellm.llms.xai.responses.transformation import XAIResponsesAPIConfig from litellm.types.router import GenericLiteLLMParams from litellm.utils import get_optional_params, validate_environment From 0f99e3e6b812d689ec4cb0fd5568324a9d3315d3 Mon Sep 17 00:00:00 2001 From: hx <1367557521@qq.com> Date: Mon, 5 Oct 2026 19:48:51 +0800 Subject: [PATCH 10/15] fix(xai): annotate error-class overrides and regenerate schema.d.ts with enterprise routes --- litellm/llms/xai/chat/transformation.py | 2 +- litellm/llms/xai/responses/transformation.py | 2 +- ui/litellm-dashboard/src/lib/http/schema.d.ts | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/litellm/llms/xai/chat/transformation.py b/litellm/llms/xai/chat/transformation.py index 3898d482620..3ae42116a57 100644 --- a/litellm/llms/xai/chat/transformation.py +++ b/litellm/llms/xai/chat/transformation.py @@ -129,7 +129,7 @@ class XAIChatConfig(OpenAIGPTConfig): ) def get_error_class( - self, error_message: str, status_code: int, headers: dict[str, str] | httpx.Headers + self, error_message: str, status_code: int, headers: dict | httpx.Headers # mutable-ok: mirrors the base class signature ) -> BaseLLMException: return super().get_error_class( error_message=error_message, diff --git a/litellm/llms/xai/responses/transformation.py b/litellm/llms/xai/responses/transformation.py index 02658098cc2..90b332992a3 100644 --- a/litellm/llms/xai/responses/transformation.py +++ b/litellm/llms/xai/responses/transformation.py @@ -258,7 +258,7 @@ class XAIResponsesAPIConfig(OpenAIResponsesAPIConfig): return join_api_base(api_base, "responses") def get_error_class( - self, error_message: str, status_code: int, headers: dict[str, str] | httpx.Headers + self, error_message: str, status_code: int, headers: dict | httpx.Headers # mutable-ok: mirrors the base class signature ) -> BaseLLMException: return super().get_error_class( error_message=error_message, diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 2601cc73397..ffa26fb59df 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -53155,6 +53155,8 @@ export interface operations { object_team_id?: string | null; /** @description Filter by token (key hash) present in before_value or updated_values JSON (PostgreSQL only) */ object_key_hash?: string | null; + /** @description Match a row whose id, object_id, changed_by, or changed_by_api_key equals this value */ + search?: string | null; /** @description Column to sort by (e.g. 'updated_at', 'action', 'table_name') */ sort_by?: string | null; /** @description Sort order ('asc' or 'desc') */ From 9dea675be599aa1ed513f7a17daa1cfceade0d2b Mon Sep 17 00:00:00 2001 From: hx <1367557521@qq.com> Date: Mon, 5 Oct 2026 20:08:43 +0800 Subject: [PATCH 11/15] chore(ui): regenerate schema.d.ts including vendored enterprise routes --- ui/litellm-dashboard/src/lib/http/schema.d.ts | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index ffa26fb59df..984c8ebc7df 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -9247,6 +9247,24 @@ export interface paths { patch: operations["lens_request_lens__path__patch"]; trace?: never; }; + "/liteadmin/slack/connect/{token}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Connect Page */ + get: operations["connect_page_liteadmin_slack_connect__token__get"]; + put?: never; + /** Connect Account */ + post: operations["connect_account_liteadmin_slack_connect__token__post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/litellm/.well-known/litellm-ui-config": { parameters: { query?: never; @@ -64628,6 +64646,68 @@ export interface operations { }; }; }; + connect_page_liteadmin_slack_connect__token__get: { + parameters: { + query?: never; + header?: never; + path: { + token: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "text/html": string; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + connect_account_liteadmin_slack_connect__token__post: { + parameters: { + query?: never; + header?: never; + path: { + token: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "text/html": string; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; get_ui_config_litellm__well_known_litellm_ui_config_get: { parameters: { query?: never; From 32becd849877cb144dcb8c6bf6db9319bc4357fa Mon Sep 17 00:00:00 2001 From: hx <1367557521@qq.com> Date: Tue, 6 Oct 2026 00:44:25 +0800 Subject: [PATCH 12/15] style: format xai get_error_class signatures for ruff format --- litellm/llms/xai/chat/transformation.py | 5 ++++- litellm/llms/xai/responses/transformation.py | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/litellm/llms/xai/chat/transformation.py b/litellm/llms/xai/chat/transformation.py index 3ae42116a57..73e3244a0e0 100644 --- a/litellm/llms/xai/chat/transformation.py +++ b/litellm/llms/xai/chat/transformation.py @@ -129,7 +129,10 @@ class XAIChatConfig(OpenAIGPTConfig): ) def get_error_class( - self, error_message: str, status_code: int, headers: dict | httpx.Headers # mutable-ok: mirrors the base class signature + self, + error_message: str, + status_code: int, + headers: dict | httpx.Headers, # mutable-ok: mirrors the base class signature ) -> BaseLLMException: return super().get_error_class( error_message=error_message, diff --git a/litellm/llms/xai/responses/transformation.py b/litellm/llms/xai/responses/transformation.py index 90b332992a3..49dc7960c93 100644 --- a/litellm/llms/xai/responses/transformation.py +++ b/litellm/llms/xai/responses/transformation.py @@ -258,7 +258,10 @@ class XAIResponsesAPIConfig(OpenAIResponsesAPIConfig): return join_api_base(api_base, "responses") def get_error_class( - self, error_message: str, status_code: int, headers: dict | httpx.Headers # mutable-ok: mirrors the base class signature + self, + error_message: str, + status_code: int, + headers: dict | httpx.Headers, # mutable-ok: mirrors the base class signature ) -> BaseLLMException: return super().get_error_class( error_message=error_message, From 91ea07c9c757519c4041ad7b142f33f410e4b437 Mon Sep 17 00:00:00 2001 From: hx <1367557521@qq.com> Date: Thu, 15 Oct 2026 19:40:08 +0800 Subject: [PATCH 13/15] fix(xai): register xai_oauth_token_file as a server-owned param Route the token file through the shared server-owned gate so it is refused in body litellm_params, stored key/team fallback targets and health check params, and so only proxy admins can set it on a deployment. Drops the parallel operator-only check this replaces --- litellm/proxy/auth/auth_utils.py | 13 ++----------- litellm/types/utils.py | 2 ++ tests/unit/proxy/auth/test_auth_utils.py | 7 +++++++ .../auth/test_banned_params_extra_body.py | 1 - .../test_model_management_endpoints.py | 19 +++++++++++-------- tests/unit/types/test_router.py | 2 ++ 6 files changed, 24 insertions(+), 20 deletions(-) diff --git a/litellm/proxy/auth/auth_utils.py b/litellm/proxy/auth/auth_utils.py index 9a03dd1f7ad..35f860e105a 100644 --- a/litellm/proxy/auth/auth_utils.py +++ b/litellm/proxy/auth/auth_utils.py @@ -393,10 +393,6 @@ def _build_banned_observability_params() -> frozenset[str]: ) -# Credential selectors that name an account stored on the proxy host, so no -# client-side opt-in can hand them to a caller. Only the deployment config sets them. -_OPERATOR_ONLY_REQUEST_BODY_PARAMS: Final[tuple[str, ...]] = ("xai_oauth_token_file",) - _BANNED_REQUEST_BODY_PARAMS: Final[tuple[str, ...]] = ( "api_base", "base_url", @@ -421,7 +417,6 @@ _BANNED_REQUEST_BODY_PARAMS: Final[tuple[str, ...]] = ( # caller-supplied value is the same exfil shape as # ``aws_web_identity_token`` on the Bedrock path. "azure_ad_token", - *_OPERATOR_ONLY_REQUEST_BODY_PARAMS, # Endpoint-targeting fields that retarget the outbound request or # an observability callback. An attacker-controlled value either # exfiltrates the request payload (incl. messages + admin-set @@ -475,6 +470,8 @@ _BANNED_REQUEST_BODY_PARAMS: Final[tuple[str, ...]] = ( "github_copilot_host", "user_provider_credentials", "github_copilot_user_session", + # Picks which xAI OAuth account stored on the proxy host signs the call. + "xai_oauth_token_file", # Observability credentials, hosts, and project identifiers: derived # from the canonical ``_supported_callback_params`` allowlist so new # integrations are covered automatically. Sorted for stable iteration @@ -506,12 +503,6 @@ def _check_banned_params( reject_server_owned_wif_params(wif_body) if not manages_deployments: reject_federated_credential_reference(body) - operator_only: Final = next((param for param in _OPERATOR_ONLY_REQUEST_BODY_PARAMS if param in body), None) - if operator_only is not None: - raise ValueError( - f"Rejected Request: {operator_only} is not allowed in request body. " - "Set it on the deployment's litellm_params in your proxy config.yaml instead." - ) for param in _BANNED_REQUEST_BODY_PARAMS: if param not in body: continue diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 0c3440e88f7..7ce8327fb60 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -4447,11 +4447,13 @@ def credential_api_key_secret_reference(credential_values: Mapping[str, object]) return {**credential_values, "api_key": reference} +xai_oauth_litellm_params: Final = ("xai_oauth_token_file",) server_owned_wif_litellm_params: Final = ( anthropic_wif_litellm_params + openai_wif_litellm_params + oauth_token_exchange_litellm_params + github_copilot_oauth_litellm_params + + xai_oauth_litellm_params + ("chatgpt_auth_file",) ) secret_bearing_wif_litellm_params: Final = tuple(sorted(WIF_SECRET_BEARING_KEYS)) diff --git a/tests/unit/proxy/auth/test_auth_utils.py b/tests/unit/proxy/auth/test_auth_utils.py index 695aeb9b678..d30a7373dd0 100644 --- a/tests/unit/proxy/auth/test_auth_utils.py +++ b/tests/unit/proxy/auth/test_auth_utils.py @@ -5388,6 +5388,7 @@ class TestIsRequestBodySafeBlocksXaiOauthTokenFile: {"model": "grok-4", "extra_body": {"xai_oauth_token_file": "auth-bob.json"}}, {"model": "grok-4", "metadata": {"xai_oauth_token_file": "auth-bob.json"}}, {"model": "grok-4", "fallbacks": [{"model": "grok-4", "xai_oauth_token_file": "auth-bob.json"}]}, + {"model": "grok-4", "litellm_params": {"xai_oauth_token_file": "auth-bob.json"}}, ], ) def test_rejected_even_under_proxy_wide_opt_in(self, request_body): @@ -5399,6 +5400,12 @@ class TestIsRequestBodySafeBlocksXaiOauthTokenFile: model="grok-4", ) + def test_rejected_in_stored_fallback_targets_and_health_check_params(self): + from litellm.proxy.auth.auth_utils import reject_server_owned_wif_params + + with pytest.raises(ValueError, match="xai_oauth_token_file"): + reject_server_owned_wif_params({"model": "xai/grok-4", "xai_oauth_token_file": "auth-bob.json"}) + def test_rejected_even_when_deployment_lists_it_as_clientside_configurable(self): from litellm import Router diff --git a/tests/unit/proxy/auth/test_banned_params_extra_body.py b/tests/unit/proxy/auth/test_banned_params_extra_body.py index 6c621b03cba..e87b206a40a 100644 --- a/tests/unit/proxy/auth/test_banned_params_extra_body.py +++ b/tests/unit/proxy/auth/test_banned_params_extra_body.py @@ -28,7 +28,6 @@ from litellm.proxy.auth.auth_utils import is_request_body_safe # noqa: E402 "base_url", "vertex_credentials", "azure_ad_token", - "xai_oauth_token_file", ], ) def test_banned_param_under_extra_body_is_rejected(banned_param): diff --git a/tests/unit/proxy/management_endpoints/test_model_management_endpoints.py b/tests/unit/proxy/management_endpoints/test_model_management_endpoints.py index ab6cfcfebfe..920d4f264df 100644 --- a/tests/unit/proxy/management_endpoints/test_model_management_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_model_management_endpoints.py @@ -10187,14 +10187,21 @@ class TestNonAdminCannotPersistWifFieldsOnModel: mock_prisma.db.litellm_proxymodeltable.update.assert_not_called() @pytest.mark.asyncio - async def test_patch_model_non_admin_cannot_set_openai_wif_field(self): + @pytest.mark.parametrize( + ("model", "field", "value"), + [ + ("openai/gpt-4o-mini", "openai_identity_token_file", "/var/run/secrets/tokens/attacker"), + ("xai/grok-4", "xai_oauth_token_file", "auth-other-account.json"), + ], + ) + async def test_patch_model_non_admin_cannot_set_server_owned_token_file(self, model: str, field: str, value: str): from litellm.proxy.management_endpoints.model_management_endpoints import ( patch_model, ) non_admin = UserAPIKeyAuth(user_id="team_admin", user_role=LitellmUserRoles.INTERNAL_USER) existing_row = MagicMock() - existing_row.litellm_params = {"model": "openai/gpt-4o-mini"} + existing_row.litellm_params = {"model": model} existing_row.model_dump.return_value = { "model_name": "gpt", "litellm_params": existing_row.litellm_params, @@ -10231,14 +10238,10 @@ class TestNonAdminCannotPersistWifFieldsOnModel: ) as exc_info: await patch_model( model_id="m1", - patch_data=updateDeployment( - litellm_params=updateLiteLLMParams( - openai_identity_token_file="/var/run/secrets/tokens/attacker", - ) - ), + patch_data=updateDeployment(litellm_params=updateLiteLLMParams.model_validate({field: value})), user_api_key_dict=non_admin, ) - assert getattr(exc_info.value, "param", "") == "openai_identity_token_file" + assert getattr(exc_info.value, "param", "") == field mock_prisma.db.litellm_proxymodeltable.update.assert_not_called() @pytest.mark.asyncio diff --git a/tests/unit/types/test_router.py b/tests/unit/types/test_router.py index 7855eba0042..c981072d7f9 100644 --- a/tests/unit/types/test_router.py +++ b/tests/unit/types/test_router.py @@ -27,6 +27,7 @@ from litellm.types.utils import ( oauth_token_exchange_litellm_params, openai_wif_litellm_params, server_owned_wif_litellm_params, + xai_oauth_litellm_params, ) @@ -367,6 +368,7 @@ def test_server_owned_registry_includes_anthropic_openai_and_oauth_token_exchang + openai_wif_litellm_params + oauth_token_exchange_litellm_params + github_copilot_oauth_litellm_params + + xai_oauth_litellm_params + ("chatgpt_auth_file",) ) assert set(openai_wif_litellm_params) == { From 5e211a4a866b2b2fa24e42ed7dee5459a8d9dd32 Mon Sep 17 00:00:00 2001 From: hx <1367557521@qq.com> Date: Thu, 15 Oct 2026 20:08:42 +0800 Subject: [PATCH 14/15] fix(xai): type spending-limit error-class overrides without bare dict --- litellm/llms/xai/chat/transformation.py | 12 +++++------- litellm/llms/xai/responses/transformation.py | 11 ++++------- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/litellm/llms/xai/chat/transformation.py b/litellm/llms/xai/chat/transformation.py index 73e3244a0e0..b0f8734fa56 100644 --- a/litellm/llms/xai/chat/transformation.py +++ b/litellm/llms/xai/chat/transformation.py @@ -14,6 +14,7 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import ( strip_name_from_messages, ) from litellm.llms.base_llm.chat.transformation import BaseLLMException +from litellm.llms.openai.common_utils import OpenAIError from litellm.llms.xai.common_utils import XAIModelInfo, xai_error_status_code, xai_reported_cost_in_usd from litellm.llms.xai.cost_calculator import ( apply_server_side_tool_usage_details_to_usage, @@ -129,15 +130,12 @@ class XAIChatConfig(OpenAIGPTConfig): ) def get_error_class( - self, - error_message: str, - status_code: int, - headers: dict | httpx.Headers, # mutable-ok: mirrors the base class signature + self, error_message: str, status_code: int, headers: Mapping[str, str] | httpx.Headers ) -> BaseLLMException: - return super().get_error_class( - error_message=error_message, + return OpenAIError( status_code=xai_error_status_code(status_code, error_message), - headers=headers, + message=error_message, + headers=httpx.Headers(headers), ) def get_supported_openai_params(self, model: str) -> list: diff --git a/litellm/llms/xai/responses/transformation.py b/litellm/llms/xai/responses/transformation.py index 49dc7960c93..2383643bd20 100644 --- a/litellm/llms/xai/responses/transformation.py +++ b/litellm/llms/xai/responses/transformation.py @@ -258,15 +258,12 @@ class XAIResponsesAPIConfig(OpenAIResponsesAPIConfig): return join_api_base(api_base, "responses") def get_error_class( - self, - error_message: str, - status_code: int, - headers: dict | httpx.Headers, # mutable-ok: mirrors the base class signature + self, error_message: str, status_code: int, headers: Mapping[str, str] | httpx.Headers ) -> BaseLLMException: - return super().get_error_class( - error_message=error_message, + return BaseLLMException( status_code=xai_error_status_code(status_code, error_message), - headers=headers, + message=error_message, + headers=httpx.Headers(headers), ) def transform_response_api_response( From e4942c2ee162d3f6395abe421c1a20b9c346a583 Mon Sep 17 00:00:00 2001 From: hx <1367557521@qq.com> Date: Thu, 15 Oct 2026 20:15:00 +0800 Subject: [PATCH 15/15] fix(xai): forward xai_oauth_token_file through the typed completion passthrough --- litellm/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/main.py b/litellm/main.py index 42d0e1a7d53..a5c95652355 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -6205,7 +6205,6 @@ def completion( rpm=kwargs.get("rpm"), use_xai_oauth=kwargs.get("use_xai_oauth", False), chatgpt_auth_file=routing_kwargs.get("chatgpt_auth_file"), - xai_oauth_token_file=kwargs.get("xai_oauth_token_file"), gigachat_scope=kwargs.get("gigachat_scope"), gigachat_auth_url=kwargs.get("gigachat_auth_url"), gigachat_access_token=kwargs.get("gigachat_access_token"), @@ -6223,6 +6222,7 @@ def completion( "fireworks_forward_user_id", "responses_api_version", "allowed_openai_params", + "xai_oauth_token_file", ) if key in kwargs },