""" Unit tests for GigaChat OAuth authenticator. Tests get_access_token and get_access_token_async covering token resolution from litellm_params/env, credential validation, caching, and error handling. """ import time from unittest.mock import AsyncMock, MagicMock, patch import httpx import pytest from litellm.llms.gigachat import authenticator from litellm.llms.gigachat.authenticator import ( GigaChatAuthError, TOKEN_EXPIRY_BUFFER_MS, get_access_token, get_access_token_async, ) AUTH_MODULE = "litellm.llms.gigachat.authenticator" def _future_expires_at_ms(offset_seconds: float = 3600) -> int: return int(time.time() * 1000 + offset_seconds * 1000) def _past_expires_at_ms(offset_seconds: float = 3600) -> int: return int(time.time() * 1000 - offset_seconds * 1000) @pytest.fixture(autouse=True) def _isolate_token_cache(): """Each test gets a fresh module-level token cache to avoid cross-test leakage.""" with patch(f"{AUTH_MODULE}._token_cache", new=MagicMock()): authenticator._token_cache.get_cache.return_value = None authenticator._token_cache.set_cache = MagicMock() yield class TestGetAccessTokenSync: def test_returns_token_from_litellm_params(self): token = get_access_token(litellm_params={"gigachat_access_token": "param-token"}) assert token == "param-token" authenticator._token_cache.get_cache.assert_not_called() @patch(f"{AUTH_MODULE}.get_secret_str") def test_returns_token_from_env(self, mock_get_secret): mock_get_secret.return_value = "env-access-token" token = get_access_token() assert token == "env-access-token" @patch(f"{AUTH_MODULE}._get_credentials", return_value=None) @patch(f"{AUTH_MODULE}.get_secret_str", return_value=None) def test_raises_when_no_credentials(self, mock_get_secret, mock_get_creds): with pytest.raises(GigaChatAuthError) as exc_info: get_access_token() assert exc_info.value.status_code == 401 assert "credentials not provided" in exc_info.value.message @patch(f"{AUTH_MODULE}._request_token_sync") @patch(f"{AUTH_MODULE}._get_auth_url", return_value="https://auth.example.com") @patch(f"{AUTH_MODULE}._get_scope", return_value="GIGACHAT_API_PERS") @patch(f"{AUTH_MODULE}._get_credentials", return_value=None) @patch(f"{AUTH_MODULE}.get_secret_str", return_value=None) def test_raises_when_no_credentials_even_with_other_resolvers( self, mock_get_secret, mock_get_creds, mock_scope, mock_auth_url, mock_request ): with pytest.raises(GigaChatAuthError) as exc_info: get_access_token() assert exc_info.value.status_code == 401 mock_request.assert_not_called() @patch(f"{AUTH_MODULE}._request_token_sync") @patch(f"{AUTH_MODULE}._get_auth_url", return_value="https://auth.example.com") @patch(f"{AUTH_MODULE}._get_scope", return_value="GIGACHAT_API_PERS") @patch(f"{AUTH_MODULE}._get_credentials", return_value="creds-from-env") @patch(f"{AUTH_MODULE}.get_secret_str", return_value=None) def test_requests_new_token_and_caches(self, mock_get_secret, mock_creds, mock_scope, mock_auth_url, mock_request): token = "fresh-token" expires_at = _future_expires_at_ms() mock_request.return_value = (token, expires_at) result = get_access_token() assert result == token mock_request.assert_called_once_with("creds-from-env", "GIGACHAT_API_PERS", "https://auth.example.com") authenticator._token_cache.set_cache.assert_called_once() call_args = authenticator._token_cache.set_cache.call_args assert call_args.args[1] == (token, expires_at) assert call_args.kwargs["ttl"] > 0 @patch(f"{AUTH_MODULE}._request_token_sync") @patch(f"{AUTH_MODULE}._get_auth_url", return_value="https://auth.example.com") @patch(f"{AUTH_MODULE}._get_scope", return_value="GIGACHAT_API_PERS") @patch(f"{AUTH_MODULE}._get_credentials", return_value="creds") @patch(f"{AUTH_MODULE}.get_secret_str", return_value=None) def test_does_not_cache_when_no_expiry(self, mock_get_secret, mock_creds, mock_scope, mock_auth_url, mock_request): mock_request.return_value = ("token-no-exp", 0) result = get_access_token() assert result == "token-no-exp" authenticator._token_cache.set_cache.assert_not_called() @patch(f"{AUTH_MODULE}._request_token_sync") @patch(f"{AUTH_MODULE}._get_auth_url", return_value="https://auth.example.com") @patch(f"{AUTH_MODULE}._get_scope", return_value="GIGACHAT_API_PERS") @patch(f"{AUTH_MODULE}._get_credentials", return_value="creds") @patch(f"{AUTH_MODULE}.get_secret_str", return_value=None) def test_does_not_cache_when_ttl_non_positive(self, mock_get_secret, mock_creds, mock_scope, mock_auth_url, mock_request): expires_at = int(time.time() * 1000) + TOKEN_EXPIRY_BUFFER_MS - 1000 mock_request.return_value = ("token", expires_at) result = get_access_token() assert result == "token" authenticator._token_cache.set_cache.assert_not_called() @patch(f"{AUTH_MODULE}._request_token_sync") @patch(f"{AUTH_MODULE}._get_auth_url", return_value="https://auth.example.com") @patch(f"{AUTH_MODULE}._get_scope", return_value="GIGACHAT_API_PERS") @patch(f"{AUTH_MODULE}._get_credentials", return_value="creds") @patch(f"{AUTH_MODULE}.get_secret_str", return_value=None) def test_returns_cached_valid_token(self, mock_get_secret, mock_creds, mock_scope, mock_auth_url, mock_request): cached_token = "cached-token" cached_expires_at = _future_expires_at_ms(offset_seconds=7200) authenticator._token_cache.get_cache.return_value = (cached_token, cached_expires_at) result = get_access_token(credentials="creds") assert result == cached_token mock_request.assert_not_called() authenticator._token_cache.set_cache.assert_not_called() @patch(f"{AUTH_MODULE}._request_token_sync") @patch(f"{AUTH_MODULE}._get_auth_url", return_value="https://auth.example.com") @patch(f"{AUTH_MODULE}._get_scope", return_value="GIGACHAT_API_PERS") @patch(f"{AUTH_MODULE}._get_credentials", return_value="creds") @patch(f"{AUTH_MODULE}.get_secret_str", return_value=None) def test_requests_new_token_when_cache_expired(self, mock_get_secret, mock_creds, mock_scope, mock_auth_url, mock_request): cached_token = "stale-token" cached_expires_at = _past_expires_at_ms(offset_seconds=10) authenticator._token_cache.get_cache.return_value = (cached_token, cached_expires_at) new_token = "refreshed-token" mock_request.return_value = (new_token, _future_expires_at_ms()) result = get_access_token(credentials="creds") assert result == new_token mock_request.assert_called_once() @patch(f"{AUTH_MODULE}._request_token_sync") @patch(f"{AUTH_MODULE}._get_auth_url", return_value="https://default-auth.example.com") @patch(f"{AUTH_MODULE}._get_scope", return_value="GIGACHAT_API_PERS") @patch(f"{AUTH_MODULE}._get_credentials", return_value="env-creds") @patch(f"{AUTH_MODULE}.get_secret_str", return_value=None) def test_litellm_params_override_scope_and_auth_url(self, mock_get_secret, mock_creds, mock_scope, mock_auth_url, mock_request): # test-quality-ok: mock-echo of internal wiring mock_request.return_value = ("token", _future_expires_at_ms()) get_access_token( litellm_params={ "gigachat_scope": "GIGACHAT_API_CORP", "gigachat_auth_url": "https://params-auth.example.com", } ) mock_request.assert_called_once_with( # test-quality-ok: mock-echo of internal wiring "env-creds", "GIGACHAT_API_CORP", "https://params-auth.example.com" ) @patch(f"{AUTH_MODULE}._request_token_sync") @patch(f"{AUTH_MODULE}._get_auth_url", return_value="https://default-auth.example.com") @patch(f"{AUTH_MODULE}._get_scope", return_value="GIGACHAT_API_PERS") @patch(f"{AUTH_MODULE}._get_credentials", return_value="env-creds") @patch(f"{AUTH_MODULE}.get_secret_str", return_value=None) def test_explicit_args_override_everything(self, mock_get_secret, mock_creds, mock_scope, mock_auth_url, mock_request): # test-quality-ok: mock-echo of internal wiring mock_request.return_value = ("token", _future_expires_at_ms()) get_access_token( credentials="explicit-creds", scope="EXPLICIT_SCOPE", auth_url="https://explicit.example.com", litellm_params={ "gigachat_scope": "PARAM_SCOPE", "gigachat_auth_url": "https://params.example.com", }, ) mock_request.assert_called_once_with( # test-quality-ok: mock-echo of internal wiring "explicit-creds", "EXPLICIT_SCOPE", "https://explicit.example.com" ) @patch(f"{AUTH_MODULE}._request_token_sync") @patch(f"{AUTH_MODULE}._get_auth_url", return_value="https://auth.example.com") @patch(f"{AUTH_MODULE}._get_scope", return_value="GIGACHAT_API_PERS") @patch(f"{AUTH_MODULE}._get_credentials", return_value="creds") @patch(f"{AUTH_MODULE}.get_secret_str", return_value=None) def test_propagates_auth_error_from_request(self, mock_get_secret, mock_creds, mock_scope, mock_auth_url, mock_request): mock_request.side_effect = GigaChatAuthError(status_code=403, message="forbidden") with pytest.raises(GigaChatAuthError) as exc_info: get_access_token() assert exc_info.value.status_code == 403 assert exc_info.value.message == "forbidden" class TestGetAccessTokenAsync: @pytest.mark.asyncio async def test_returns_token_from_litellm_params(self): token = await get_access_token_async( litellm_params={"gigachat_access_token": "param-token"} ) assert token == "param-token" authenticator._token_cache.get_cache.assert_not_called() @pytest.mark.asyncio @patch(f"{AUTH_MODULE}.get_secret_str") async def test_returns_token_from_env(self, mock_get_secret): mock_get_secret.return_value = "env-access-token" token = await get_access_token_async() assert token == "env-access-token" @pytest.mark.asyncio @patch(f"{AUTH_MODULE}._get_credentials", return_value=None) @patch(f"{AUTH_MODULE}.get_secret_str", return_value=None) async def test_raises_when_no_credentials(self, mock_get_secret, mock_get_creds): with pytest.raises(GigaChatAuthError) as exc_info: await get_access_token_async() assert exc_info.value.status_code == 401 assert "credentials not provided" in exc_info.value.message @pytest.mark.asyncio @patch(f"{AUTH_MODULE}._request_token_async", new_callable=AsyncMock) @patch(f"{AUTH_MODULE}._get_auth_url", return_value="https://auth.example.com") @patch(f"{AUTH_MODULE}._get_scope", return_value="GIGACHAT_API_PERS") @patch(f"{AUTH_MODULE}._get_credentials", return_value="creds-from-env") @patch(f"{AUTH_MODULE}.get_secret_str", return_value=None) async def test_requests_new_token_and_caches( self, mock_get_secret, mock_creds, mock_scope, mock_auth_url, mock_request ): token = "fresh-token-async" expires_at = _future_expires_at_ms() mock_request.return_value = (token, expires_at) result = await get_access_token_async() assert result == token mock_request.assert_called_once_with( "creds-from-env", "GIGACHAT_API_PERS", "https://auth.example.com" ) authenticator._token_cache.set_cache.assert_called_once() call_args = authenticator._token_cache.set_cache.call_args assert call_args.args[1] == (token, expires_at) assert call_args.kwargs["ttl"] > 0 @pytest.mark.asyncio @patch(f"{AUTH_MODULE}._request_token_async", new_callable=AsyncMock) @patch(f"{AUTH_MODULE}._get_auth_url", return_value="https://auth.example.com") @patch(f"{AUTH_MODULE}._get_scope", return_value="GIGACHAT_API_PERS") @patch(f"{AUTH_MODULE}._get_credentials", return_value="creds") @patch(f"{AUTH_MODULE}.get_secret_str", return_value=None) async def test_does_not_cache_when_no_expiry( self, mock_get_secret, mock_creds, mock_scope, mock_auth_url, mock_request ): mock_request.return_value = ("token-no-exp", 0) result = await get_access_token_async() assert result == "token-no-exp" authenticator._token_cache.set_cache.assert_not_called() @pytest.mark.asyncio @patch(f"{AUTH_MODULE}._request_token_async", new_callable=AsyncMock) @patch(f"{AUTH_MODULE}._get_auth_url", return_value="https://auth.example.com") @patch(f"{AUTH_MODULE}._get_scope", return_value="GIGACHAT_API_PERS") @patch(f"{AUTH_MODULE}._get_credentials", return_value="creds") @patch(f"{AUTH_MODULE}.get_secret_str", return_value=None) async def test_returns_cached_valid_token( self, mock_get_secret, mock_creds, mock_scope, mock_auth_url, mock_request ): cached_token = "cached-token-async" cached_expires_at = _future_expires_at_ms(offset_seconds=7200) authenticator._token_cache.get_cache.return_value = (cached_token, cached_expires_at) result = await get_access_token_async(credentials="creds") assert result == cached_token mock_request.assert_not_called() authenticator._token_cache.set_cache.assert_not_called() @pytest.mark.asyncio @patch(f"{AUTH_MODULE}._request_token_async", new_callable=AsyncMock) @patch(f"{AUTH_MODULE}._get_auth_url", return_value="https://auth.example.com") @patch(f"{AUTH_MODULE}._get_scope", return_value="GIGACHAT_API_PERS") @patch(f"{AUTH_MODULE}._get_credentials", return_value="creds") @patch(f"{AUTH_MODULE}.get_secret_str", return_value=None) async def test_requests_new_token_when_cache_expired( self, mock_get_secret, mock_creds, mock_scope, mock_auth_url, mock_request ): cached_expires_at = _past_expires_at_ms(offset_seconds=10) authenticator._token_cache.get_cache.return_value = ("stale", cached_expires_at) new_token = "refreshed-token-async" mock_request.return_value = (new_token, _future_expires_at_ms()) result = await get_access_token_async(credentials="creds") assert result == new_token mock_request.assert_called_once() @pytest.mark.asyncio @patch(f"{AUTH_MODULE}._request_token_async", new_callable=AsyncMock) @patch(f"{AUTH_MODULE}._get_auth_url", return_value="https://default-auth.example.com") @patch(f"{AUTH_MODULE}._get_scope", return_value="GIGACHAT_API_PERS") @patch(f"{AUTH_MODULE}._get_credentials", return_value="env-creds") @patch(f"{AUTH_MODULE}.get_secret_str", return_value=None) async def test_litellm_params_override_scope_and_auth_url( # test-quality-ok: mock-echo of internal wiring self, mock_get_secret, mock_creds, mock_scope, mock_auth_url, mock_request ): mock_request.return_value = ("token", _future_expires_at_ms()) await get_access_token_async( litellm_params={ "gigachat_scope": "GIGACHAT_API_CORP", "gigachat_auth_url": "https://params-auth.example.com", } ) mock_request.assert_called_once_with( # test-quality-ok: mock-echo of internal wiring "env-creds", "GIGACHAT_API_CORP", "https://params-auth.example.com" ) @pytest.mark.asyncio @patch(f"{AUTH_MODULE}._request_token_async", new_callable=AsyncMock) @patch(f"{AUTH_MODULE}._get_auth_url", return_value="https://default-auth.example.com") @patch(f"{AUTH_MODULE}._get_scope", return_value="GIGACHAT_API_PERS") @patch(f"{AUTH_MODULE}._get_credentials", return_value="env-creds") @patch(f"{AUTH_MODULE}.get_secret_str", return_value=None) async def test_explicit_args_override_everything( # test-quality-ok: mock-echo of internal wiring self, mock_get_secret, mock_creds, mock_scope, mock_auth_url, mock_request ): mock_request.return_value = ("token", _future_expires_at_ms()) await get_access_token_async( credentials="explicit-creds", scope="EXPLICIT_SCOPE", auth_url="https://explicit.example.com", litellm_params={ "gigachat_scope": "PARAM_SCOPE", "gigachat_auth_url": "https://params.example.com", }, ) mock_request.assert_called_once_with( # test-quality-ok: mock-echo of internal wiring "explicit-creds", "EXPLICIT_SCOPE", "https://explicit.example.com" ) @pytest.mark.asyncio @patch(f"{AUTH_MODULE}._request_token_async", new_callable=AsyncMock) @patch(f"{AUTH_MODULE}._get_auth_url", return_value="https://auth.example.com") @patch(f"{AUTH_MODULE}._get_scope", return_value="GIGACHAT_API_PERS") @patch(f"{AUTH_MODULE}._get_credentials", return_value="creds") @patch(f"{AUTH_MODULE}.get_secret_str", return_value=None) async def test_propagates_auth_error_from_request( self, mock_get_secret, mock_creds, mock_scope, mock_auth_url, mock_request ): mock_request.side_effect = GigaChatAuthError(status_code=403, message="forbidden") with pytest.raises(GigaChatAuthError) as exc_info: await get_access_token_async() assert exc_info.value.status_code == 403 assert exc_info.value.message == "forbidden" class TestRequestTokenSyncErrorMapping: @patch(f"{AUTH_MODULE}._get_http_client") def test_http_status_error_maps_to_auth_error(self, mock_get_client): client = MagicMock() request = httpx.Request("POST", "https://auth.example.com") response = httpx.Response(status_code=401, content=b"bad creds", request=request) http_error = httpx.HTTPStatusError("unauthorized", request=request, response=response) client.post.side_effect = http_error mock_get_client.return_value = client from litellm.llms.gigachat.authenticator import _request_token_sync with pytest.raises(GigaChatAuthError) as exc_info: _request_token_sync("creds", "GIGACHAT_API_PERS", "https://auth.example.com") assert exc_info.value.status_code == 401 assert "bad creds" in exc_info.value.message @patch(f"{AUTH_MODULE}._get_http_client") def test_request_error_maps_to_auth_error(self, mock_get_client): client = MagicMock() client.post.side_effect = httpx.ConnectError("connection refused") mock_get_client.return_value = client from litellm.llms.gigachat.authenticator import _request_token_sync with pytest.raises(GigaChatAuthError) as exc_info: _request_token_sync("creds", "GIGACHAT_API_PERS", "https://auth.example.com") assert exc_info.value.status_code == 500 assert "connection refused" in exc_info.value.message class TestRequestTokenAsyncErrorMapping: @pytest.mark.asyncio @patch(f"{AUTH_MODULE}.get_async_httpx_client") async def test_http_status_error_maps_to_auth_error(self, mock_get_client): client = MagicMock() request = httpx.Request("POST", "https://auth.example.com") response = httpx.Response(status_code=401, content=b"bad creds", request=request) http_error = httpx.HTTPStatusError("unauthorized", request=request, response=response) client.post = AsyncMock(side_effect=http_error) mock_get_client.return_value = client from litellm.llms.gigachat.authenticator import _request_token_async with pytest.raises(GigaChatAuthError) as exc_info: await _request_token_async("creds", "GIGACHAT_API_PERS", "https://auth.example.com") assert exc_info.value.status_code == 401 assert "bad creds" in exc_info.value.message @pytest.mark.asyncio @patch(f"{AUTH_MODULE}.get_async_httpx_client") async def test_request_error_maps_to_auth_error(self, mock_get_client): client = MagicMock() client.post = AsyncMock(side_effect=httpx.ConnectError("connection refused")) mock_get_client.return_value = client from litellm.llms.gigachat.authenticator import _request_token_async with pytest.raises(GigaChatAuthError) as exc_info: await _request_token_async("creds", "GIGACHAT_API_PERS", "https://auth.example.com") assert exc_info.value.status_code == 500 assert "connection refused" in exc_info.value.message class TestParseTokenResponse: def _make_response(self, body: dict) -> httpx.Response: import json return httpx.Response( status_code=200, content=json.dumps(body).encode("utf-8"), request=httpx.Request("POST", "https://auth.example.com"), ) def test_parses_tok_exp_fields(self): from litellm.llms.gigachat.authenticator import _parse_token_response token, expires_at = _parse_token_response( self._make_response({"tok": "abc", "exp": 1700000000000}) ) assert token == "abc" assert expires_at == 1700000000000 def test_parses_access_token_expires_at_fields(self): from litellm.llms.gigachat.authenticator import _parse_token_response token, expires_at = _parse_token_response( self._make_response({"access_token": "xyz", "expires_at": 1700000000000}) ) assert token == "xyz" assert expires_at == 1700000000000 def test_parses_string_expires_at(self): from litellm.llms.gigachat.authenticator import _parse_token_response token, expires_at = _parse_token_response( self._make_response({"tok": "abc", "exp": "1700000000000"}) ) assert token == "abc" assert expires_at == 1700000000000 assert isinstance(expires_at, int) def test_raises_when_no_access_token(self): from litellm.llms.gigachat.authenticator import _parse_token_response with pytest.raises(GigaChatAuthError) as exc_info: _parse_token_response(self._make_response({"exp": 1700000000000})) assert exc_info.value.status_code == 500 assert "Invalid token response" in exc_info.value.message class TestGetHttpClient: def test_reuses_cached_client_across_calls(self): """Regression: the sync OAuth path must use the shared cached httpx client, not construct a fresh HTTPHandler per token request.""" assert authenticator._get_http_client() is authenticator._get_http_client()