fix(vertex_ai): bound shared credential cache retention (#48442)

This commit is contained in:
tin-berri 2026-10-26 15:56:13 -04:00 • committed by GitHub
parent 52c11bf383
commit f96200bcea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 182 additions and 87 deletions

View file

@ -7,12 +7,14 @@ Handles Authentication and generating request urls for Vertex AI and Google AI S
import asyncio
import json
import threading
from collections import OrderedDict
from collections.abc import Mapping
from typing import TYPE_CHECKING, Any, Final, Literal, Protocol
from urllib.parse import urlparse
import litellm
from litellm._logging import verbose_logger
from litellm.constants import DEFAULT_MAX_LRU_CACHE_SIZE
from litellm.litellm_core_utils.asyncify import asyncify
from litellm.litellm_core_utils.url_utils import join_api_base
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
@ -70,16 +72,20 @@ class _VertexCredentialsObject(Protocol):
def refresh(self, request: object) -> None: ...
_VertexCredentialCacheKey = tuple[str | None, str | None]
class VertexBase:
def __init__(self) -> None:
super().__init__()
self.access_token: str | None = None
self.refresh_token: str | None = None
self._credentials: GoogleCredentialsObject | None = None
self._credentials_project_mapping: dict[
tuple[VERTEX_CREDENTIALS_TYPES | None, str | None],
self._credentials_project_mapping: OrderedDict[
_VertexCredentialCacheKey,
tuple[_VertexCredentialsObject, str | None],
] = {}
] = OrderedDict()
self._credentials_cache_lock = threading.RLock()
self.project_id: str | None = None
self.async_handler: AsyncHTTPHandler | None = None
# Per-credential-key asyncio.Lock for single-flight async refresh.
@ -91,10 +97,10 @@ class VertexBase:
# each lock; the entry is pruned when the count reaches zero, so the
# dict stays bounded even in long-running high-cardinality deployments
# without depending on any private asyncio internals.
self._async_refresh_locks: dict[tuple, asyncio.Lock] = {}
self._async_refresh_lock_refcounts: dict[tuple, int] = {}
self._async_refresh_locks: dict[_VertexCredentialCacheKey, asyncio.Lock] = {}
self._async_refresh_lock_refcounts: dict[_VertexCredentialCacheKey, int] = {}
# Tracks in-flight background refresh tasks to avoid duplicate refreshes.
self._background_refresh_tasks: dict[tuple, asyncio.Task] = {}
self._background_refresh_tasks: dict[_VertexCredentialCacheKey, asyncio.Task] = {}
# Protects the sync get_access_token refresh path.
# Use RLock so that the reauthentication retry path (which calls
# back into get_access_token while still holding the lock) can
@ -378,7 +384,7 @@ class VertexBase:
with self._sync_refresh_lock:
credentials.refresh(Request())
def _acquire_async_refresh_lock(self, credential_cache_key: tuple) -> asyncio.Lock:
def _acquire_async_refresh_lock(self, credential_cache_key: _VertexCredentialCacheKey) -> asyncio.Lock:
"""Increment the refcount and return the lock for ``credential_cache_key``.
Every call must be paired with ``_release_async_refresh_lock`` once the
@ -391,7 +397,7 @@ class VertexBase:
)
return lock
def _release_async_refresh_lock(self, credential_cache_key: tuple, lock: asyncio.Lock) -> None:
def _release_async_refresh_lock(self, credential_cache_key: _VertexCredentialCacheKey, lock: asyncio.Lock) -> None:
"""Decrement the refcount and drop the lock entry when it reaches zero.
Must be called only after the caller has released ``lock`` (i.e. once
@ -409,7 +415,7 @@ class VertexBase:
def _try_get_cached_token(
self,
credential_cache_key: tuple,
credential_cache_key: _VertexCredentialCacheKey,
project_id: str | None,
) -> tuple[str, str] | None:
"""
@ -432,7 +438,7 @@ class VertexBase:
def _try_get_usable_cached_token(
self,
credential_cache_key: tuple,
credential_cache_key: _VertexCredentialCacheKey,
project_id: str | None,
) -> tuple[str, str, "TokenState", _VertexCredentialsObject, str | None] | None:
"""
@ -458,19 +464,37 @@ class VertexBase:
return creds.token, resolved_project, token_state, creds, cached_project_id
def _unpack_cached_credentials(
self, credential_cache_key: tuple
self, credential_cache_key: _VertexCredentialCacheKey
) -> tuple[_VertexCredentialsObject | None, str | None]:
"""
Return (credentials, project_id) from the cache, or (None, None) if
not cached. Handles both tuple and legacy cache formats.
"""
if credential_cache_key not in self._credentials_project_mapping:
return None, None
cached_entry: Final = self._credentials_project_mapping[credential_cache_key]
with self._credentials_cache_lock:
cached_entry: Final = self._credentials_project_mapping.get(credential_cache_key)
if cached_entry is None:
return None, None
self._credentials_project_mapping.move_to_end(credential_cache_key)
if isinstance(cached_entry, tuple):
return cached_entry
return cached_entry, cached_entry.quota_project_id or getattr(cached_entry, "project_id", None)
def _cache_credentials(
self,
credential_cache_key: _VertexCredentialCacheKey,
credentials: _VertexCredentialsObject,
project_id: str | None,
*,
replace: bool = True,
) -> None:
with self._credentials_cache_lock:
if not replace and credential_cache_key in self._credentials_project_mapping:
return
self._credentials_project_mapping[credential_cache_key] = (credentials, project_id)
self._credentials_project_mapping.move_to_end(credential_cache_key)
while len(self._credentials_project_mapping) > max(0, DEFAULT_MAX_LRU_CACHE_SIZE):
self._credentials_project_mapping.popitem(last=False)
def _get_token_state(self, credentials: _VertexCredentialsObject) -> "TokenState":
"""
Return the token state using google-auth's TokenState enum.
@ -494,7 +518,7 @@ class VertexBase:
self,
credentials: VERTEX_CREDENTIALS_TYPES | None,
project_id: str | None,
credential_cache_key: tuple,
credential_cache_key: _VertexCredentialCacheKey,
) -> tuple[_VertexCredentialsObject, str | None]:
"""Load credentials via load_auth (in thread) and cache the result."""
try:
@ -507,17 +531,12 @@ class VertexBase:
raise
if _credentials is None:
raise ValueError("Could not resolve credentials")
self._credentials_project_mapping[credential_cache_key] = (
_credentials,
credential_project_id,
)
self._cache_credentials(credential_cache_key, _credentials, credential_project_id)
return _credentials, credential_project_id
async def _background_refresh_credentials(
self,
credentials: _VertexCredentialsObject,
credential_cache_key: tuple,
credential_project_id: str | None,
) -> None:
"""
Refresh credentials in the background without blocking the calling request.
@ -528,25 +547,13 @@ class VertexBase:
try:
verbose_logger.debug("Background proactive credential refresh")
await asyncify(self.refresh_auth)(credentials)
# Only update the cache if it still points at the credentials
# object we just refreshed. The per-key async lock is not held
# here, so a concurrent INVALID path may have already replaced
# this entry (e.g. via _handle_reauthentication_async, which
# creates a fresh credentials object). In that case our write
# would clobber the newer entry with a stale reference.
cached_creds, _ = self._unpack_cached_credentials(credential_cache_key)
if cached_creds is credentials:
self._credentials_project_mapping[credential_cache_key] = (
credentials,
credential_project_id,
)
except Exception:
verbose_logger.debug(
"Background credential refresh failed, will retry on next request",
exc_info=True,
)
async def _await_in_flight_background_refresh(self, credential_cache_key: tuple) -> None:
async def _await_in_flight_background_refresh(self, credential_cache_key: _VertexCredentialCacheKey) -> None:
"""Wait for an in-flight background refresh to finish, if any.
google-auth's ``Credentials.refresh()`` is not safe to invoke
@ -568,8 +575,7 @@ class VertexBase:
def _schedule_background_refresh(
self,
credentials: _VertexCredentialsObject,
credential_cache_key: tuple,
credential_project_id: str | None,
credential_cache_key: _VertexCredentialCacheKey,
) -> None:
"""Kick off a single background refresh for ``credential_cache_key``.
@ -581,9 +587,7 @@ class VertexBase:
if existing is not None and not existing.done():
return
self._background_refresh_tasks.pop(credential_cache_key, None)
task: Final = asyncio.create_task(
self._background_refresh_credentials(credentials, credential_cache_key, credential_project_id)
)
task: Final = asyncio.create_task(self._background_refresh_credentials(credentials))
def _drop_background_refresh_task(_fut: asyncio.Future[None]) -> None:
if self._background_refresh_tasks.get(credential_cache_key) is _fut:
@ -796,7 +800,7 @@ class VertexBase:
self,
credentials: VERTEX_CREDENTIALS_TYPES | None,
project_id: str | None,
credential_cache_key: tuple,
credential_cache_key: _VertexCredentialCacheKey,
error: Exception,
) -> tuple[str, str]:
"""
@ -822,8 +826,8 @@ class VertexBase:
)
# Clear the cached credentials
if credential_cache_key in self._credentials_project_mapping:
del self._credentials_project_mapping[credential_cache_key]
with self._credentials_cache_lock:
self._credentials_project_mapping.pop(credential_cache_key, None)
# Retry once with _retry_reauth=True to prevent infinite recursion
try:
@ -846,7 +850,7 @@ class VertexBase:
self,
credentials: VERTEX_CREDENTIALS_TYPES | None,
project_id: str | None,
credential_cache_key: tuple,
credential_cache_key: _VertexCredentialCacheKey,
error: Exception,
) -> tuple[str, str]:
"""
@ -856,7 +860,8 @@ class VertexBase:
"Handling async reauthentication for project_id: %s. Clearing cache and retrying once.", project_id
)
self._credentials_project_mapping.pop(credential_cache_key, None)
with self._credentials_cache_lock:
self._credentials_project_mapping.pop(credential_cache_key, None)
try:
(
@ -875,10 +880,7 @@ class VertexBase:
# references the OLD credentials object we just replaced, and
# leaving it would force the next request to do a redundant
# refresh/reauth before realizing the cached creds are stale.
self._credentials_project_mapping[resolved_cache_key] = (
_credentials,
credential_project_id,
)
self._cache_credentials(resolved_cache_key, _credentials, credential_project_id)
if _credentials.token is None or not isinstance(_credentials.token, str):
raise ValueError(
@ -924,21 +926,11 @@ class VertexBase:
# Convert dict credentials to string for caching
cache_credentials: Final = json.dumps(credentials) if isinstance(credentials, dict) else credentials
credential_cache_key: Final = (cache_credentials, project_id)
_credentials: _VertexCredentialsObject | None = None
verbose_logger.debug("Checking cached credentials for project_id: %s", project_id)
if credential_cache_key in self._credentials_project_mapping:
_credentials, credential_project_id = self._unpack_cached_credentials(credential_cache_key)
if _credentials is not None:
verbose_logger.debug("Cached credentials found for project_id: %s.", project_id)
# Retrieve both credentials and cached project_id
cached_entry: Final = self._credentials_project_mapping[credential_cache_key]
verbose_logger.debug("cached_entry: %s", cached_entry)
if isinstance(cached_entry, tuple):
_credentials, credential_project_id = cached_entry
else:
# Backward compatibility with old cache format
_credentials = cached_entry
credential_project_id = _credentials.quota_project_id or getattr(_credentials, "project_id", None)
verbose_logger.debug(
"Using cached credentials for project_id: %s",
credential_project_id,
@ -963,10 +955,7 @@ class VertexBase:
f"Could not resolve credentials - either dynamically or from environment, for project_id: {project_id}"
)
# Cache the project_id and credentials from load_auth result (resolved project_id)
self._credentials_project_mapping[credential_cache_key] = (
_credentials,
credential_project_id,
)
self._cache_credentials(credential_cache_key, _credentials, credential_project_id)
## VALIDATE CREDENTIALS
verbose_logger.debug("Validating credentials")
@ -974,11 +963,7 @@ class VertexBase:
project_id = credential_project_id
# Update cache with resolved project_id for future lookups
resolved_cache_key: Final = (cache_credentials, project_id)
if resolved_cache_key not in self._credentials_project_mapping:
self._credentials_project_mapping[resolved_cache_key] = (
_credentials,
credential_project_id,
)
self._cache_credentials(resolved_cache_key, _credentials, credential_project_id, replace=False)
# Check if credentials are None before accessing attributes
if _credentials is None:
@ -991,10 +976,6 @@ class VertexBase:
try:
verbose_logger.debug("Credentials expired, refreshing")
self.refresh_auth(_credentials)
self._credentials_project_mapping[credential_cache_key] = (
_credentials,
credential_project_id,
)
except Exception as e:
# if refresh fails, it's possible the user has re-authenticated via `gcloud auth application-default login`
# in this case, we should try to reload the credentials by clearing the cache and retrying
@ -1043,9 +1024,9 @@ class VertexBase:
# callers on the lock just to schedule that refresh.
usable: Final = self._try_get_usable_cached_token(credential_cache_key, project_id)
if usable is not None:
cached_token, resolved_project, token_state, creds, cached_project_id = usable
cached_token, resolved_project, token_state, creds, _cached_project_id = usable
if token_state == TokenState.STALE:
self._schedule_background_refresh(creds, credential_cache_key, cached_project_id)
self._schedule_background_refresh(creds, credential_cache_key)
return cached_token, resolved_project
# === SLOW PATH (per-key lock) ===
@ -1075,10 +1056,7 @@ class VertexBase:
# reauth that only repopulated the unresolved key), which
# would force the next request through an unnecessary
# refresh/reauth cycle.
self._credentials_project_mapping[resolved_cache_key] = (
_credentials,
credential_project_id,
)
self._cache_credentials(resolved_cache_key, _credentials, credential_project_id)
# Use google-auth's token_state to decide refresh strategy:
# - STALE: token is usable but within REFRESH_THRESHOLD (3:45) of
@ -1098,7 +1076,6 @@ class VertexBase:
self._schedule_background_refresh(
_credentials,
credential_cache_key,
credential_project_id,
)
return current_token, project_id
@ -1117,10 +1094,6 @@ class VertexBase:
try:
verbose_logger.debug("Credentials expired, refreshing")
await asyncify(self.refresh_auth)(_credentials)
self._credentials_project_mapping[credential_cache_key] = (
_credentials,
credential_project_id,
)
except Exception as e:
if "Reauthentication is needed" in str(e):
verbose_logger.debug("Reauthentication needed, clearing cache and retrying")

View file

@ -6,13 +6,17 @@ import os
import pathlib
import re
import traceback
from typing import Final
from datetime import datetime, timedelta
from threading import Event
from typing import Final, Literal
from unittest.mock import MagicMock, call, patch
import google.auth.exceptions
import pytest
import requests
import responses
from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH
from litellm.constants import DEFAULT_MAX_LRU_CACHE_SIZE, DEFAULT_MAX_RECURSE_DEPTH
import litellm
@ -2589,3 +2593,120 @@ def test_get_access_token_reraises_malformed_service_account_credentials_without
assert [record.getMessage().split(". Error: ")[0] for record in caplog.records] == [
"Failed to load vertex credentials. Check to see if credentials containing partial/invalid information"
]
def _authorized_user_cache_credentials(identity: str) -> str:
return json.dumps(
{
"type": "authorized_user",
"client_id": identity,
"client_secret": "synthetic-secret",
"refresh_token": identity,
"quota_project_id": identity,
}
)
@pytest.mark.parametrize("is_async", [False, True], ids=["sync", "async"])
@pytest.mark.parametrize("inferred_project", [False, True], ids=["explicit", "inferred"])
@pytest.mark.asyncio
async def test_credential_cache_bounds_retention_and_reloads_least_recently_used(
is_async: bool, inferred_project: bool
) -> None:
vertex: Final = VertexBase()
limit: Final = max(0, DEFAULT_MAX_LRU_CACHE_SIZE)
with (
patch("google.auth._helpers.utcnow", return_value=datetime(2025, 1, 1)),
responses.RequestsMock() as oauth,
):
def token_response(request: requests.PreparedRequest) -> tuple[int, dict[str, str], str]:
return 200, {}, json.dumps({"access_token": f"token-{len(oauth.calls) + 1}", "expires_in": 3600})
oauth.add_callback(responses.POST, "https://oauth2.googleapis.com/token", callback=token_response)
async def access(index: int, infer: bool = False) -> tuple[str, str]:
identity: Final = f"project-{index}"
credentials: Final = _authorized_user_cache_credentials(identity)
project: Final = None if infer else identity
result: Final = (
await vertex.get_access_token_async(credentials, project)
if is_async
else vertex.get_access_token(credentials, project)
)
assert result[1] == identity
assert len(vertex._credentials_project_mapping) <= limit
return result
if limit == 0:
assert await access(0, inferred_project) == ("token-1", "project-0")
assert await access(0, inferred_project) == ("token-2", "project-0")
return
for index in range(limit):
assert await access(index) == (f"token-{index + 1}", f"project-{index}")
assert await access(0) == ("token-1", "project-0")
assert len(oauth.calls) == limit
assert await access(limit, inferred_project) == (f"token-{limit + 1}", f"project-{limit}")
assert len(oauth.calls) == limit + 1
if limit > (2 if inferred_project else 1):
assert await access(0) == ("token-1", "project-0")
assert len(oauth.calls) == limit + 1
evicted: Final = 1 if limit > 1 else 0
assert await access(evicted) == (f"token-{limit + 2}", f"project-{evicted}")
assert len(oauth.calls) == limit + 2
@pytest.mark.parametrize("mode", ["sync", "async", "background"])
@pytest.mark.skipif(DEFAULT_MAX_LRU_CACHE_SIZE < 2, reason="Eviction during refresh requires two retained entries")
@pytest.mark.asyncio
async def test_evicted_credentials_finish_refresh_without_being_readmitted(
mode: Literal["sync", "async", "background"],
) -> None:
vertex: Final = VertexBase()
limit: Final = DEFAULT_MAX_LRU_CACHE_SIZE
now: Final = datetime(2025, 1, 1)
refresh_started: Final = Event()
release_refresh: Final = Event()
donor: Final = _authorized_user_cache_credentials("donor")
target: Final = _authorized_user_cache_credentials("target")
with patch("google.auth._helpers.utcnow", return_value=now) as clock, responses.RequestsMock() as oauth:
def token_response(request: requests.PreparedRequest) -> tuple[int, dict[str, str], str]:
body: Final = request.body.decode() if isinstance(request.body, bytes) else request.body
assert isinstance(body, str)
is_target: Final = "refresh_token=target" in body
if is_target and len(oauth.calls) == limit:
refresh_started.set()
assert release_refresh.wait(5), "OAuth refresh was not released"
return 200, {}, json.dumps(
{"access_token": f"token-{len(oauth.calls) + 1}", "expires_in": 3600 if is_target else 86400}
)
oauth.add_callback(responses.POST, "https://oauth2.googleapis.com/token", callback=token_response)
assert vertex.get_access_token(donor, None) == ("token-1", "donor")
assert vertex.get_access_token(donor, None) == ("token-1", "donor")
for index in range(limit - 2):
assert vertex.get_access_token(donor, f"filler-{index}") == (f"token-{index + 2}", f"filler-{index}")
assert vertex.get_access_token(target, "target") == (f"token-{limit}", "target")
clock.return_value = now + timedelta(seconds=3550 if mode == "background" else 7200)
refresh: Final = asyncio.create_task(
asyncio.to_thread(vertex.get_access_token, target, "target")
if mode == "sync"
else vertex.get_access_token_async(target, "target")
)
try:
assert await asyncio.to_thread(refresh_started.wait, 5), "OAuth refresh did not start"
for index in range(limit - 2):
assert vertex.get_access_token(donor, f"filler-{index}") == (f"token-{index + 2}", f"filler-{index}")
assert vertex.get_access_token(donor, None) == ("token-1", "donor")
assert len(vertex._credentials_project_mapping) == limit
assert (target, "target") not in vertex._credentials_project_mapping
finally:
release_refresh.set()
completed: Final = await refresh
await asyncio.gather(*tuple(vertex._background_refresh_tasks.values()))
expected_token: Final = f"token-{limit if mode == 'background' else limit + 1}"
assert completed == (expected_token, "target")
assert len(oauth.calls) == limit + 1
assert (target, "target") not in vertex._credentials_project_mapping
assert vertex.get_access_token(target, "target") == (f"token-{limit + 2}", "target")
assert len(vertex._credentials_project_mapping) == limit

View file

@ -1,6 +1,7 @@
import base64
import binascii
import json
from collections import OrderedDict
from dataclasses import dataclass
from typing import Final
@ -120,7 +121,7 @@ def manager(monkeypatch: pytest.MonkeyPatch) -> GoogleSecretManager:
monkeypatch.setattr(litellm, "secret_manager_client", None)
monkeypatch.setattr(litellm, "_key_management_system", None)
monkeypatch.setattr(litellm, "_key_management_settings", KeyManagementSettings())
monkeypatch.setattr(litellm.vertex_chat_completion, "_credentials_project_mapping", {})
monkeypatch.setattr(litellm.vertex_chat_completion, "_credentials_project_mapping", OrderedDict())
monkeypatch.setattr(google.auth, "default", _default_credentials)
monkeypatch.setenv("GOOGLE_SECRET_MANAGER_PROJECT_ID", PROJECT_ID)
monkeypatch.delenv("GOOGLE_SECRET_MANAGER_ALWAYS_READ_SECRET_MANAGER", raising=False)