mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-14 09:03:16 +00:00
fix(vertex_ai): reuse one Google token across RAG Engine searches and cold bursts (#51239)
* fix(vertex_ai): reuse one Google token across vector store searches * fix(vertex_ai): load Vertex credentials once when a cold worker gets a burst * test(vertex_ai): count Google token fetches for RAG Engine searches through the proxy * test(vertex_ai): type the fixtures and token callback in the concurrent search test * fix(vertex_ai): lock Google token loads per credential and reload a key Google rejects A shared RAG Engine config made one store's slow token fetch hold up every other store, and kept a rotated key file's deleted key until a restart. First loads and expired refreshes now take a lock per credentials and project, refreshes take a lock per credentials object, and any RefreshError reloads the credentials once. * fix(vertex_ai): share one Google token load or failure per credential and reload rejected keys on async calls * test(vertex_ai): check async callers sharing an expired credential refresh it once * fix(vertex_ai): reload Google credentials only when a refresh is rejected for good A retryable refresh error such as a 503 now raises without reloading the key, so an outage does not double google-auth's retries. Credentials that cannot be weak-referenced share the config's refresh lock again, as on main. The burst test for a rejected key moves to VertexBase and holds the token response until the other callers have started * fix(vertex_ai): reload Google credentials only when Google answers invalid_grant google-auth leaves its retryable flag False on metadata server, impersonation, and workload identity outages, and on a 502, so the previous check reloaded the key file on those outages and doubled the token tries. Now only an invalid_grant answer or "Reauthentication is needed" reloads The sync reload also refills the cache under the key the caller asked with, so a call without vertex_project keeps one cached copy, as the async reload already did * fix(vertex_ai): pick up a swapped Google key file on the next call The credential cache key now carries the key file's inode, mtime and size, for a vertex_credentials path and for GOOGLE_APPLICATION_CREDENTIALS. A key file that is replaced or rewritten, such as a rotated Kubernetes secret, is loaded on the next call instead of after Google rejects the old key or the proxy restarts * fix(vertex_ai): keep the earlier key while a replaced key file does not load A key file caught halfway through a non-atomic write, or replaced by one that does not load, now keeps the credentials loaded from its earlier version in use and logs a warning, as the cache did before key files were re-read. The next complete file is loaded on the next call. The pub/sub test no longer writes a key file it only reaches through the seeded cache * fix(vertex_ai): try a replaced key file again when its first token fetch fails The earlier key now stands in only when the replaced file does not load. A token fetch that fails on the new key raises like any first load, so the next call tries the new key again instead of keeping the old one * fix(vertex): raise when an async reload keeps an earlier key Google rejected --------- Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
parent
c4c16c7b3f
commit
9ba216d521
8 changed files with 707 additions and 75 deletions
|
|
@ -159,6 +159,14 @@ def load_vertex_credentials_source(credentials: str) -> VertexCredentialsSource:
|
|||
return _read_json_file(credentials)
|
||||
|
||||
|
||||
def vertex_credentials_file_version(path: str) -> str | None:
|
||||
try:
|
||||
stat: Final = os.stat(path)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
return f"{stat.st_ino}:{stat.st_mtime_ns}:{stat.st_size}"
|
||||
|
||||
|
||||
def build_vertex_credentials(
|
||||
source: VertexCredentialsJson, build: Callable[[Mapping[str, object]], _Credentials]
|
||||
) -> _Credentials:
|
||||
|
|
|
|||
|
|
@ -361,3 +361,6 @@ class VertexVectorStoreConfig(BaseVectorStoreConfig, VertexBase):
|
|||
status_code=response.status_code,
|
||||
headers=response.headers,
|
||||
)
|
||||
|
||||
|
||||
vertex_rag_api_vector_store_config: Final = VertexVectorStoreConfig()
|
||||
|
|
|
|||
|
|
@ -6,10 +6,13 @@ Handles Authentication and generating request urls for Vertex AI and Google AI S
|
|||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import weakref
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import Callable, Mapping
|
||||
from concurrent.futures import Future
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal, Protocol
|
||||
from urllib.parse import urlparse
|
||||
|
|
@ -35,6 +38,7 @@ from .credentials_source import (
|
|||
build_vertex_credentials,
|
||||
load_vertex_credentials_source,
|
||||
raise_vertex_credentials_failure,
|
||||
vertex_credentials_file_version,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -100,6 +104,32 @@ class _VertexCredentialsObject(Protocol):
|
|||
|
||||
|
||||
_VertexCredentialCacheKey = tuple[str | None, str | None]
|
||||
_KEY_FILE_VERSION_SEPARATOR: Final = "\0"
|
||||
|
||||
|
||||
def _credential_cache_identity(credentials: VERTEX_CREDENTIALS_TYPES | None) -> str | None:
|
||||
if isinstance(credentials, dict):
|
||||
return json.dumps(credentials)
|
||||
key_file: Final = credentials if credentials is not None else os.environ.get("GOOGLE_APPLICATION_CREDENTIALS")
|
||||
version: Final = None if key_file is None else vertex_credentials_file_version(key_file)
|
||||
if version is None:
|
||||
return credentials
|
||||
return f"{credentials or ''}{_KEY_FILE_VERSION_SEPARATOR}{version}"
|
||||
|
||||
|
||||
def _is_other_version_of_key_file(
|
||||
candidate: _VertexCredentialCacheKey, credential_cache_key: _VertexCredentialCacheKey
|
||||
) -> bool:
|
||||
candidate_identity, candidate_project_id = candidate
|
||||
identity, project_id = credential_cache_key
|
||||
if candidate_identity is None or identity is None or candidate_identity == identity:
|
||||
return False
|
||||
key_file, separator, _ = candidate_identity.partition(_KEY_FILE_VERSION_SEPARATOR)
|
||||
return (
|
||||
bool(separator)
|
||||
and candidate_project_id == project_id
|
||||
and key_file == identity.partition(_KEY_FILE_VERSION_SEPARATOR)[0]
|
||||
)
|
||||
|
||||
|
||||
class VertexBase:
|
||||
|
|
@ -128,11 +158,13 @@ class VertexBase:
|
|||
self._async_refresh_lock_refcounts: dict[_VertexCredentialCacheKey, int] = {}
|
||||
# Tracks in-flight background refresh tasks to avoid duplicate refreshes.
|
||||
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
|
||||
# re-acquire it without deadlocking the current thread.
|
||||
self._sync_refresh_lock = threading.RLock()
|
||||
self._sync_token_flights: weakref.WeakValueDictionary[_VertexCredentialCacheKey, Future[tuple[str, str]]] = (
|
||||
weakref.WeakValueDictionary()
|
||||
)
|
||||
self._credential_refresh_locks: weakref.WeakKeyDictionary[_VertexCredentialsObject, threading.RLock] = (
|
||||
weakref.WeakKeyDictionary()
|
||||
)
|
||||
self._unkeyable_credentials_refresh_lock: Final = threading.RLock()
|
||||
|
||||
@staticmethod
|
||||
def get_vertex_region(vertex_region: str | None, model: str) -> str:
|
||||
|
|
@ -399,18 +431,64 @@ class VertexBase:
|
|||
except ImportError:
|
||||
raise ImportError(GOOGLE_IMPORT_ERROR_MESSAGE)
|
||||
|
||||
# Serialize all refreshes on this VertexBase across threads.
|
||||
# ``credentials.refresh()`` is not safe to call concurrently on the
|
||||
# same credentials object, and this method is invoked from three
|
||||
# places that can run on different threads:
|
||||
# - sync ``get_access_token`` (already holds ``_sync_refresh_lock``)
|
||||
# - sync ``get_access_token``
|
||||
# - the async slow path (via ``asyncify`` in a worker thread)
|
||||
# - the background proactive refresh task (via ``asyncify``)
|
||||
# ``_sync_refresh_lock`` is an ``RLock`` so reentrant acquisition
|
||||
# from the sync path is safe.
|
||||
with self._sync_refresh_lock:
|
||||
with self._credential_refresh_lock(credentials):
|
||||
credentials.refresh(Request())
|
||||
|
||||
def _credential_refresh_lock(self, credentials: _VertexCredentialsObject) -> threading.RLock:
|
||||
with self._credentials_cache_lock:
|
||||
try:
|
||||
return self._credential_refresh_locks.setdefault(credentials, threading.RLock())
|
||||
except TypeError:
|
||||
return self._unkeyable_credentials_refresh_lock
|
||||
|
||||
def _refresh_if_still_needed(
|
||||
self,
|
||||
credentials: _VertexCredentialsObject,
|
||||
needs_refresh: Callable[[_VertexCredentialsObject], object],
|
||||
) -> None:
|
||||
with self._credential_refresh_lock(credentials):
|
||||
if needs_refresh(credentials):
|
||||
verbose_logger.debug("Credentials expired, refreshing")
|
||||
self.refresh_auth(credentials)
|
||||
|
||||
def _needs_blocking_refresh(self, credentials: _VertexCredentialsObject) -> bool:
|
||||
from google.auth.credentials import TokenState
|
||||
|
||||
return not isinstance(credentials.token, str) or self._get_token_state(credentials) == TokenState.INVALID
|
||||
|
||||
def _join_sync_token_flight(
|
||||
self, credential_cache_key: _VertexCredentialCacheKey
|
||||
) -> tuple[Future[tuple[str, str]], bool]:
|
||||
with self._credentials_cache_lock:
|
||||
in_flight: Final = self._sync_token_flights.get(credential_cache_key)
|
||||
if in_flight is not None:
|
||||
return in_flight, False
|
||||
flight: Final[Future[tuple[str, str]]] = Future()
|
||||
self._sync_token_flights[credential_cache_key] = flight
|
||||
return flight, True
|
||||
|
||||
@staticmethod
|
||||
def _refresh_rejected(error: Exception) -> bool:
|
||||
from google.auth.exceptions import RefreshError
|
||||
|
||||
match error:
|
||||
case RefreshError(args=(str(), {"error": "invalid_grant"})):
|
||||
return True
|
||||
case _:
|
||||
return "Reauthentication is needed" in str(error)
|
||||
|
||||
@staticmethod
|
||||
def _token_fetch_failed(error: Exception) -> bool:
|
||||
from google.auth.exceptions import RefreshError, TransportError
|
||||
|
||||
return isinstance(error, (RefreshError, TransportError))
|
||||
|
||||
def _acquire_async_refresh_lock(self, credential_cache_key: _VertexCredentialCacheKey) -> asyncio.Lock:
|
||||
"""Increment the refcount and return the lock for ``credential_cache_key``.
|
||||
|
||||
|
|
@ -506,6 +584,51 @@ class VertexBase:
|
|||
return cached_entry
|
||||
return cached_entry, cached_entry.quota_project_id or getattr(cached_entry, "project_id", None)
|
||||
|
||||
def _load_auth_or_earlier_key_file(
|
||||
self,
|
||||
credentials: VERTEX_CREDENTIALS_TYPES | None,
|
||||
project_id: str | None,
|
||||
credential_cache_key: _VertexCredentialCacheKey,
|
||||
) -> tuple[_VertexCredentialsObject | None, str | None]:
|
||||
try:
|
||||
return self.load_auth(credentials=credentials, project_id=project_id)
|
||||
except Exception as e:
|
||||
earlier: Final = (
|
||||
None
|
||||
if self._token_fetch_failed(e)
|
||||
else self._credentials_from_an_earlier_key_file(credential_cache_key, e)
|
||||
)
|
||||
if earlier is None:
|
||||
verbose_logger.exception(
|
||||
"Failed to load vertex credentials. Check to see if credentials containing partial/invalid information. Error: %s",
|
||||
e,
|
||||
)
|
||||
raise
|
||||
return earlier
|
||||
|
||||
def _credentials_from_an_earlier_key_file(
|
||||
self, credential_cache_key: _VertexCredentialCacheKey, error: Exception
|
||||
) -> tuple[_VertexCredentialsObject, str | None] | None:
|
||||
with self._credentials_cache_lock:
|
||||
earlier_key: Final = next(
|
||||
(
|
||||
key
|
||||
for key in reversed(self._credentials_project_mapping)
|
||||
if _is_other_version_of_key_file(key, credential_cache_key)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if earlier_key is None:
|
||||
return None
|
||||
earlier_credentials, earlier_project_id = self._unpack_cached_credentials(earlier_key)
|
||||
if earlier_credentials is None:
|
||||
return None
|
||||
verbose_logger.warning(
|
||||
"Vertex: the replaced credentials file did not load, so the credentials loaded before it stay in use: %s",
|
||||
error,
|
||||
)
|
||||
return earlier_credentials, earlier_project_id
|
||||
|
||||
def _cache_credentials(
|
||||
self,
|
||||
credential_cache_key: _VertexCredentialCacheKey,
|
||||
|
|
@ -548,14 +671,9 @@ class VertexBase:
|
|||
credential_cache_key: _VertexCredentialCacheKey,
|
||||
) -> tuple[_VertexCredentialsObject, str | None]:
|
||||
"""Load credentials via load_auth (in thread) and cache the result."""
|
||||
try:
|
||||
_credentials, credential_project_id = await asyncify(self.load_auth)(
|
||||
credentials=credentials,
|
||||
project_id=project_id,
|
||||
)
|
||||
except Exception as e:
|
||||
verbose_logger.exception("Failed to load vertex credentials: %s", str(e))
|
||||
raise
|
||||
_credentials, credential_project_id = await asyncify(self._load_auth_or_earlier_key_file)(
|
||||
credentials, project_id, credential_cache_key
|
||||
)
|
||||
if _credentials is None:
|
||||
raise ValueError("Could not resolve credentials")
|
||||
self._cache_credentials(credential_cache_key, _credentials, credential_project_id)
|
||||
|
|
@ -842,7 +960,8 @@ class VertexBase:
|
|||
Handle reauthentication when credentials refresh fails.
|
||||
|
||||
This method clears the cached credentials and attempts to reload them once.
|
||||
It should only be called when "Reauthentication is needed" error occurs.
|
||||
It should only be called when Google rejects the cached credentials' refresh,
|
||||
e.g. "Reauthentication is needed" or a key file rotated to a new key.
|
||||
|
||||
Args:
|
||||
credentials: The original credentials
|
||||
|
|
@ -863,12 +982,13 @@ class VertexBase:
|
|||
# Clear the cached credentials
|
||||
with self._credentials_cache_lock:
|
||||
self._credentials_project_mapping.pop(credential_cache_key, None)
|
||||
self._credentials_project_mapping.pop((credential_cache_key[0], project_id), None)
|
||||
|
||||
# Retry once with _retry_reauth=True to prevent infinite recursion
|
||||
try:
|
||||
return self.get_access_token(
|
||||
credentials=credentials,
|
||||
project_id=project_id,
|
||||
project_id=credential_cache_key[1],
|
||||
_retry_reauth=True,
|
||||
)
|
||||
except Exception as retry_error:
|
||||
|
|
@ -897,6 +1017,7 @@ class VertexBase:
|
|||
|
||||
with self._credentials_cache_lock:
|
||||
self._credentials_project_mapping.pop(credential_cache_key, None)
|
||||
self._credentials_project_mapping.pop((credential_cache_key[0], project_id), None)
|
||||
|
||||
try:
|
||||
(
|
||||
|
|
@ -909,14 +1030,14 @@ class VertexBase:
|
|||
)
|
||||
if project_id is None and isinstance(credential_project_id, str):
|
||||
project_id = credential_project_id
|
||||
cache_credentials: Final = json.dumps(credentials) if isinstance(credentials, dict) else credentials
|
||||
resolved_cache_key: Final = (cache_credentials, project_id)
|
||||
resolved_cache_key: Final = (credential_cache_key[0], project_id)
|
||||
# Always overwrite — any pre-existing entry at the resolved key
|
||||
# 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._cache_credentials(resolved_cache_key, _credentials, credential_project_id)
|
||||
|
||||
await asyncify(self._refresh_if_still_needed)(_credentials, self._needs_blocking_refresh)
|
||||
if _credentials.token is None or not isinstance(_credentials.token, str):
|
||||
raise ValueError(
|
||||
f"Could not resolve credentials token. Got None or non-string token (type={type(_credentials.token).__name__})"
|
||||
|
|
@ -958,11 +1079,32 @@ class VertexBase:
|
|||
Tuple of (access_token, project_id)
|
||||
"""
|
||||
|
||||
# Convert dict credentials to string for caching
|
||||
cache_credentials: Final = json.dumps(credentials) if isinstance(credentials, dict) else credentials
|
||||
cache_credentials: Final = _credential_cache_identity(credentials)
|
||||
credential_cache_key: Final = (cache_credentials, project_id)
|
||||
verbose_logger.debug("Checking cached credentials for project_id: %s", project_id)
|
||||
if _retry_reauth:
|
||||
return self._resolve_access_token(credentials, project_id, credential_cache_key, _retry_reauth=True)
|
||||
flight, leads = self._join_sync_token_flight(credential_cache_key)
|
||||
if not leads:
|
||||
return flight.result()
|
||||
try:
|
||||
flight.set_result(self._resolve_access_token(credentials, project_id, credential_cache_key))
|
||||
except BaseException as e:
|
||||
flight.set_exception(e)
|
||||
raise
|
||||
finally:
|
||||
with self._credentials_cache_lock:
|
||||
self._sync_token_flights.pop(credential_cache_key, None)
|
||||
return flight.result()
|
||||
|
||||
def _resolve_access_token(
|
||||
self,
|
||||
credentials: VERTEX_CREDENTIALS_TYPES | None,
|
||||
project_id: str | None,
|
||||
credential_cache_key: _VertexCredentialCacheKey,
|
||||
_retry_reauth: bool = False,
|
||||
) -> tuple[str, str]:
|
||||
cache_credentials: Final = credential_cache_key[0]
|
||||
_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)
|
||||
|
|
@ -976,14 +1118,9 @@ class VertexBase:
|
|||
"Credential cache key not found for project_id: %s, loading new credentials", project_id
|
||||
)
|
||||
|
||||
try:
|
||||
_credentials, credential_project_id = self.load_auth(credentials=credentials, project_id=project_id)
|
||||
except Exception as e:
|
||||
verbose_logger.exception(
|
||||
"Failed to load vertex credentials. Check to see if credentials containing partial/invalid information. Error: %s",
|
||||
e,
|
||||
)
|
||||
raise e
|
||||
_credentials, credential_project_id = self._load_auth_or_earlier_key_file(
|
||||
credentials, project_id, credential_cache_key
|
||||
)
|
||||
|
||||
if _credentials is None:
|
||||
raise ValueError(
|
||||
|
|
@ -1005,23 +1142,19 @@ class VertexBase:
|
|||
raise ValueError("Credentials are None after loading")
|
||||
|
||||
if _credentials.expired:
|
||||
with self._sync_refresh_lock:
|
||||
# Double-check after acquiring lock
|
||||
if _credentials.expired:
|
||||
try:
|
||||
verbose_logger.debug("Credentials expired, refreshing")
|
||||
self.refresh_auth(_credentials)
|
||||
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
|
||||
if "Reauthentication is needed" in str(e) and not _retry_reauth:
|
||||
return self._handle_reauthentication(
|
||||
credentials=credentials,
|
||||
project_id=project_id,
|
||||
credential_cache_key=credential_cache_key,
|
||||
error=e,
|
||||
)
|
||||
raise e
|
||||
try:
|
||||
self._refresh_if_still_needed(_credentials, lambda loaded: loaded.expired)
|
||||
except Exception as e:
|
||||
# if refresh fails, it's possible the user has re-authenticated via `gcloud auth application-default login`
|
||||
# or rotated the key file, so we should try to reload the credentials by clearing the cache and retrying
|
||||
if self._refresh_rejected(e) and not _retry_reauth:
|
||||
return self._handle_reauthentication(
|
||||
credentials=credentials,
|
||||
project_id=project_id,
|
||||
credential_cache_key=credential_cache_key,
|
||||
error=e,
|
||||
)
|
||||
raise e
|
||||
|
||||
## VALIDATION STEP
|
||||
if _credentials.token is None or not isinstance(_credentials.token, str):
|
||||
|
|
@ -1048,7 +1181,7 @@ class VertexBase:
|
|||
"""
|
||||
from google.auth.credentials import TokenState
|
||||
|
||||
cache_credentials: Final = json.dumps(credentials) if isinstance(credentials, dict) else credentials
|
||||
cache_credentials: Final = _credential_cache_identity(credentials)
|
||||
credential_cache_key: Final = (cache_credentials, project_id)
|
||||
|
||||
# === FAST PATH (no lock) ===
|
||||
|
|
@ -1128,9 +1261,9 @@ class VertexBase:
|
|||
# Token is expired or missing — must block until refresh completes.
|
||||
try:
|
||||
verbose_logger.debug("Credentials expired, refreshing")
|
||||
await asyncify(self.refresh_auth)(_credentials)
|
||||
await asyncify(self._refresh_if_still_needed)(_credentials, self._needs_blocking_refresh)
|
||||
except Exception as e:
|
||||
if "Reauthentication is needed" in str(e):
|
||||
if self._refresh_rejected(e):
|
||||
verbose_logger.debug("Reauthentication needed, clearing cache and retrying")
|
||||
return await self._handle_reauthentication_async(
|
||||
credentials=credentials,
|
||||
|
|
|
|||
|
|
@ -11031,10 +11031,10 @@ class ProviderConfigManager:
|
|||
def _get_vertex_ai_vector_stores_config(api_type: str | None) -> BaseVectorStoreConfig | None:
|
||||
if api_type == "rag_api" or api_type is None:
|
||||
from litellm.llms.vertex_ai.vector_stores.rag_api.transformation import (
|
||||
VertexVectorStoreConfig,
|
||||
vertex_rag_api_vector_store_config,
|
||||
)
|
||||
|
||||
return VertexVectorStoreConfig()
|
||||
return vertex_rag_api_vector_store_config
|
||||
if api_type == "search_api":
|
||||
from litellm.llms.vertex_ai.vector_stores.search_api.transformation import (
|
||||
vertex_search_api_vector_store_config,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ from typing import Final
|
|||
from urllib.parse import urlsplit
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from integration._support.client import Gateway
|
||||
from integration._support.vertex import service_account_json
|
||||
from integration._support.wire import Reply, Request, wire_server
|
||||
from openai import OpenAI
|
||||
from pydantic import BaseModel, JsonValue, TypeAdapter
|
||||
|
|
@ -30,6 +32,9 @@ _ALIAS_BODY: Final[dict[str, JsonValue]] = {
|
|||
"rewrite_query": None,
|
||||
}
|
||||
_ALIAS_REQUEST_BODY: Final[dict[str, JsonValue]] = {"query": "c"}
|
||||
_VERTEX_PROJECT: Final = "scripted-search-project"
|
||||
_VERTEX_TOKEN: Final = "scripted-search-token"
|
||||
_VERTEX_SEARCH_COUNT: Final = 3
|
||||
_SEARCH_RESULT: Final[dict[str, JsonValue]] = {
|
||||
"file_id": "file-search-wire",
|
||||
"filename": "search.txt",
|
||||
|
|
@ -139,3 +144,61 @@ def test_vector_store_search_forwards_all_search_fields_on_both_routes(gateway:
|
|||
("POST", f"/v1{search_path}"),
|
||||
("POST", f"/v1{search_path}"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("api_type", "search_target", "search_reply"),
|
||||
(
|
||||
(
|
||||
"search_api",
|
||||
"/store:search",
|
||||
{"results": [{"id": "doc-1", "document": {"id": "doc-1", "derivedStructData": {"title": "hit"}}}]},
|
||||
),
|
||||
("rag_api", "/store:retrieveContexts", {"contexts": {"contexts": []}}),
|
||||
),
|
||||
)
|
||||
def test_vertex_vector_store_reuses_one_google_token_across_searches(
|
||||
gateway: Gateway, api_type: str, search_target: str, search_reply: dict[str, JsonValue]
|
||||
) -> None:
|
||||
store_id: Final = f"vs_vertex_{uuid.uuid4().hex}"
|
||||
|
||||
def respond(request: Request) -> Reply:
|
||||
assert request.method == "POST"
|
||||
if request.target == "/_oauth/token":
|
||||
return Reply(
|
||||
body=json.dumps({"access_token": _VERTEX_TOKEN, "token_type": "Bearer", "expires_in": 3600}).encode()
|
||||
)
|
||||
assert request.target == search_target, request.target
|
||||
assert request.headers["authorization"] == f"Bearer {_VERTEX_TOKEN}"
|
||||
return Reply(body=json.dumps(search_reply).encode())
|
||||
|
||||
with wire_server(respond) as wire, gateway.scenario() as scenario:
|
||||
registered: Final = gateway.request(
|
||||
"POST",
|
||||
"/vector_store/new",
|
||||
{
|
||||
"vector_store_id": store_id,
|
||||
"custom_llm_provider": f"vertex_ai/{api_type}",
|
||||
"litellm_params": {
|
||||
"api_base": f"{wire.url}/store",
|
||||
"vertex_project": _VERTEX_PROJECT,
|
||||
"vertex_location": "global",
|
||||
"vertex_credentials": service_account_json(_VERTEX_PROJECT, wire.url),
|
||||
},
|
||||
},
|
||||
)
|
||||
assert registered.status_code == 200, registered.text
|
||||
scenario.cleanups.callback(gateway.post, "/vector_store/delete", {"vector_store_id": store_id})
|
||||
|
||||
responses: Final = tuple(
|
||||
gateway.request("POST", f"/v1/vector_stores/{store_id}/search", {"query": "hit"})
|
||||
for _ in range(_VERTEX_SEARCH_COUNT)
|
||||
)
|
||||
assert [response.status_code for response in responses] == [200] * _VERTEX_SEARCH_COUNT, [
|
||||
response.text for response in responses
|
||||
]
|
||||
|
||||
assert [request.target for request in wire.drain()] == [
|
||||
"/_oauth/token",
|
||||
*(search_target,) * _VERTEX_SEARCH_COUNT,
|
||||
]
|
||||
|
|
|
|||
|
|
@ -93,22 +93,7 @@ async def test_construct_request_headers_project_id_from_env(monkeypatch):
|
|||
|
||||
|
||||
def _authorized_user_credentials(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, project_id: str) -> str:
|
||||
credentials_path: Final = tmp_path / "google-auth.json"
|
||||
credentials_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "authorized_user",
|
||||
"client_id": "unit-client",
|
||||
"client_secret": "unit-secret",
|
||||
"refresh_token": "unit-refresh-token",
|
||||
"token": "unit-access-token",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"quota_project_id": "unit-project",
|
||||
"expiry": "2099-01-01T00:00:00Z",
|
||||
}
|
||||
)
|
||||
)
|
||||
credentials_path_string: Final = str(credentials_path)
|
||||
credentials_path_string: Final = str(tmp_path / "google-auth.json")
|
||||
credentials: Final = _CachedVertexCredentials(token="unit-access-token", quota_project_id=project_id)
|
||||
vertex_chat_completion: Final = litellm.vertex_chat_completion
|
||||
monkeypatch.setitem(
|
||||
|
|
|
|||
|
|
@ -5,10 +5,14 @@ import logging
|
|||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import time
|
||||
import traceback
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime, timedelta
|
||||
from threading import Event
|
||||
from typing import Final, Literal
|
||||
from functools import partial
|
||||
from threading import Event, Semaphore
|
||||
from typing import Final, Literal, NoReturn
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
|
||||
import google.auth.exceptions
|
||||
|
|
@ -2595,14 +2599,286 @@ def test_get_access_token_reraises_malformed_service_account_credentials_without
|
|||
]
|
||||
|
||||
|
||||
def _authorized_user_cache_credentials(identity: str) -> str:
|
||||
@pytest.mark.parametrize("is_async", [False, True], ids=["sync", "async"])
|
||||
@pytest.mark.asyncio
|
||||
async def test_expired_credentials_shared_by_an_inferred_and_an_explicit_project_refresh_once(is_async: bool) -> None:
|
||||
vertex: Final = VertexBase()
|
||||
now: Final = datetime(2025, 1, 1)
|
||||
credentials: Final = _authorized_user_cache_credentials("shared")
|
||||
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]:
|
||||
if len(oauth.calls) == 1:
|
||||
time.sleep(0.3)
|
||||
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)
|
||||
assert vertex.get_access_token(credentials, None) == ("token-1", "shared")
|
||||
clock.return_value = now + timedelta(seconds=7200)
|
||||
fetch: Final = vertex.get_access_token_async if is_async else partial(asyncio.to_thread, vertex.get_access_token)
|
||||
tokens: Final = tuple(await asyncio.gather(*(fetch(credentials, project) for project in (None, "shared"))))
|
||||
|
||||
assert tokens == (("token-2", "shared"), ("token-2", "shared"))
|
||||
assert len(oauth.calls) == 2
|
||||
|
||||
|
||||
def test_callers_that_join_a_rejected_google_token_fetch_share_its_error_and_the_next_call_retries() -> None:
|
||||
vertex: Final = VertexBase()
|
||||
credentials: Final = _authorized_user_cache_credentials("revoked")
|
||||
leader_fetching: Final = Event()
|
||||
followers_calling: Final = Semaphore(0)
|
||||
|
||||
def follow() -> tuple[str, str]:
|
||||
followers_calling.release()
|
||||
return vertex.get_access_token(credentials, "unit-project")
|
||||
|
||||
def revoked_token_response(request: requests.PreparedRequest) -> tuple[int, dict[str, str], str]:
|
||||
if not leader_fetching.is_set():
|
||||
leader_fetching.set()
|
||||
assert all(followers_calling.acquire(timeout=5) for _ in range(4))
|
||||
time.sleep(0.2)
|
||||
return 400, {}, json.dumps({"error": "invalid_grant", "error_description": "Token has been expired or revoked."})
|
||||
|
||||
with responses.RequestsMock() as oauth, ThreadPoolExecutor(max_workers=5) as pool:
|
||||
oauth.add_callback(responses.POST, "https://oauth2.googleapis.com/token", callback=revoked_token_response)
|
||||
leader: Final = pool.submit(vertex.get_access_token, credentials, "unit-project")
|
||||
assert leader_fetching.wait(timeout=5)
|
||||
followers: Final = tuple(pool.submit(follow) for _ in range(4))
|
||||
errors: Final = tuple(call.exception(timeout=10) for call in (leader, *followers))
|
||||
|
||||
assert len(oauth.calls) == 1
|
||||
assert all(isinstance(error, google.auth.exceptions.RefreshError) for error in errors)
|
||||
with pytest.raises(google.auth.exceptions.RefreshError, match="invalid_grant"):
|
||||
vertex.get_access_token(credentials, "unit-project")
|
||||
assert len(oauth.calls) == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("is_async", [False, True], ids=["sync", "async"])
|
||||
@pytest.mark.parametrize("project", ["unit-project", None], ids=["explicit", "inferred"])
|
||||
@pytest.mark.asyncio
|
||||
async def test_callers_reload_a_rotated_key_file_once_when_google_rejects_the_old_key(
|
||||
tmp_path: pathlib.Path, is_async: bool, project: str | None
|
||||
) -> None:
|
||||
vertex: Final = VertexBase()
|
||||
now: Final = datetime(2025, 1, 1)
|
||||
key_file: Final = tmp_path / "vertex-key.json"
|
||||
key_file.write_text(_authorized_user_cache_credentials("old-key", quota_project="unit-project"))
|
||||
old_key_revoked: Final = Event()
|
||||
|
||||
def token_response(request: requests.PreparedRequest) -> tuple[int, dict[str, str], str]:
|
||||
form: Final = request.body.decode() if isinstance(request.body, bytes) else str(request.body)
|
||||
is_old_key: Final = "old-key" in form
|
||||
if is_old_key and old_key_revoked.is_set():
|
||||
return 400, {}, json.dumps({"error": "invalid_grant", "error_description": "Token has been expired or revoked."})
|
||||
return 200, {}, json.dumps({"access_token": "old-token" if is_old_key else "new-token", "expires_in": 3600})
|
||||
|
||||
fetch: Final = vertex.get_access_token_async if is_async else partial(asyncio.to_thread, vertex.get_access_token)
|
||||
with patch("google.auth._helpers.utcnow", return_value=now) as clock, responses.RequestsMock() as oauth:
|
||||
oauth.add_callback(responses.POST, "https://oauth2.googleapis.com/token", callback=token_response)
|
||||
assert await fetch(str(key_file), project) == ("old-token", "unit-project")
|
||||
_rewrite_keeping_its_version(
|
||||
key_file, _authorized_user_cache_credentials("new-key", quota_project="unit-project")
|
||||
)
|
||||
old_key_revoked.set()
|
||||
clock.return_value = now + timedelta(seconds=7200)
|
||||
assert await fetch(str(key_file), project) == ("new-token", "unit-project")
|
||||
assert await fetch(str(key_file), project) == ("new-token", "unit-project")
|
||||
assert len(oauth.calls) == 3
|
||||
|
||||
|
||||
@pytest.mark.parametrize("is_async", [False, True], ids=["sync", "async"])
|
||||
@pytest.mark.parametrize(
|
||||
("outage_status", "outage_body"),
|
||||
[
|
||||
(503, json.dumps({"error": "server_error", "error_description": "Service unavailable."})),
|
||||
(502, "<html>Bad Gateway</html>"),
|
||||
],
|
||||
ids=["503", "502"],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_google_outage_during_refresh_keeps_the_cached_key_instead_of_reloading_it(
|
||||
tmp_path: pathlib.Path, is_async: bool, outage_status: int, outage_body: str
|
||||
) -> None:
|
||||
vertex: Final = VertexBase()
|
||||
now: Final = datetime(2025, 1, 1)
|
||||
key_file: Final = tmp_path / "vertex-key.json"
|
||||
key_file.write_text(_authorized_user_cache_credentials("old-key"))
|
||||
google_down: Final = Event()
|
||||
|
||||
def token_response(request: requests.PreparedRequest) -> tuple[int, dict[str, str], str]:
|
||||
if google_down.is_set():
|
||||
return outage_status, {}, outage_body
|
||||
return 200, {}, json.dumps({"access_token": "old-token", "expires_in": 3600})
|
||||
|
||||
fetch: Final = vertex.get_access_token_async if is_async else partial(asyncio.to_thread, vertex.get_access_token)
|
||||
with (
|
||||
patch("google.auth._helpers.utcnow", return_value=now) as clock,
|
||||
patch("google.auth._exponential_backoff.time"),
|
||||
responses.RequestsMock() as oauth,
|
||||
):
|
||||
oauth.add_callback(responses.POST, "https://oauth2.googleapis.com/token", callback=token_response)
|
||||
assert await fetch(str(key_file), "unit-project") == ("old-token", "unit-project")
|
||||
_rewrite_keeping_its_version(key_file, _authorized_user_cache_credentials("new-key"))
|
||||
google_down.set()
|
||||
clock.return_value = now + timedelta(seconds=7200)
|
||||
with pytest.raises(google.auth.exceptions.RefreshError):
|
||||
await fetch(str(key_file), "unit-project")
|
||||
assert not any("new-key" in str(call.request.body) for call in oauth.calls)
|
||||
|
||||
|
||||
def _rewrite_keeping_its_version(key_file: pathlib.Path, contents: str) -> None:
|
||||
before: Final = key_file.stat()
|
||||
key_file.write_text(contents)
|
||||
os.utime(key_file, ns=(before.st_atime_ns, before.st_mtime_ns))
|
||||
|
||||
|
||||
def _move_into_place(key_file: pathlib.Path, contents: str) -> None:
|
||||
before: Final = key_file.stat()
|
||||
staged: Final = key_file.with_name("staged-key.json")
|
||||
staged.write_text(contents)
|
||||
os.utime(staged, ns=(before.st_atime_ns, before.st_mtime_ns))
|
||||
os.replace(staged, key_file)
|
||||
|
||||
|
||||
def _rewrite_later(key_file: pathlib.Path, contents: str) -> None:
|
||||
before: Final = key_file.stat()
|
||||
key_file.write_text(contents)
|
||||
os.utime(key_file, ns=(before.st_atime_ns, before.st_mtime_ns + 1_000_000_000))
|
||||
|
||||
|
||||
def _rewrite_longer(key_file: pathlib.Path, contents: str) -> None:
|
||||
_rewrite_keeping_its_version(key_file, contents.replace("new-key", "new-key-v2"))
|
||||
|
||||
|
||||
def _old_or_new_token_response(request: requests.PreparedRequest) -> tuple[int, dict[str, str], str]:
|
||||
form: Final = request.body.decode() if isinstance(request.body, bytes) else str(request.body)
|
||||
return 200, {}, json.dumps({"access_token": "old-token" if "old-key" in form else "new-token", "expires_in": 3600})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("is_async", [False, True], ids=["sync", "async"])
|
||||
@pytest.mark.parametrize("from_adc_env", [False, True], ids=["vertex_credentials", "GOOGLE_APPLICATION_CREDENTIALS"])
|
||||
@pytest.mark.parametrize(
|
||||
"swap_key_file",
|
||||
[_move_into_place, _rewrite_later, _rewrite_longer],
|
||||
ids=["moved_into_place", "rewritten_later", "rewritten_longer"],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_swapped_key_file_is_used_on_the_next_call_while_the_old_key_still_works(
|
||||
tmp_path: pathlib.Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
is_async: bool,
|
||||
from_adc_env: bool,
|
||||
swap_key_file: Callable[[pathlib.Path, str], None],
|
||||
) -> None:
|
||||
vertex: Final = VertexBase()
|
||||
key_file: Final = tmp_path / "vertex-key.json"
|
||||
key_file.write_text(_authorized_user_cache_credentials("old-key", quota_project="unit-project"))
|
||||
if from_adc_env:
|
||||
monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", str(key_file))
|
||||
credentials: Final = None if from_adc_env else str(key_file)
|
||||
|
||||
fetch: Final = vertex.get_access_token_async if is_async else partial(asyncio.to_thread, vertex.get_access_token)
|
||||
with responses.RequestsMock() as oauth:
|
||||
oauth.add_callback(responses.POST, "https://oauth2.googleapis.com/token", callback=_old_or_new_token_response)
|
||||
assert await fetch(credentials, "unit-project") == ("old-token", "unit-project")
|
||||
swap_key_file(key_file, _authorized_user_cache_credentials("new-key", quota_project="unit-project"))
|
||||
assert await fetch(credentials, "unit-project") == ("new-token", "unit-project")
|
||||
assert await fetch(credentials, "unit-project") == ("new-token", "unit-project")
|
||||
assert len(oauth.calls) == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("is_async", [False, True], ids=["sync", "async"])
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_key_file_replaced_by_one_that_does_not_load_keeps_the_earlier_key_until_it_is_fixed(
|
||||
tmp_path: pathlib.Path, is_async: bool
|
||||
) -> None:
|
||||
vertex: Final = VertexBase()
|
||||
key_file: Final = tmp_path / "vertex-key.json"
|
||||
key_file.write_text(_authorized_user_cache_credentials("old-key", quota_project="unit-project"))
|
||||
|
||||
fetch: Final = vertex.get_access_token_async if is_async else partial(asyncio.to_thread, vertex.get_access_token)
|
||||
with responses.RequestsMock() as oauth:
|
||||
oauth.add_callback(responses.POST, "https://oauth2.googleapis.com/token", callback=_old_or_new_token_response)
|
||||
assert await fetch(str(key_file), "unit-project") == ("old-token", "unit-project")
|
||||
_move_into_place(key_file, '{"type": "authorized_user", "client_id": "half-wri')
|
||||
assert await fetch(str(key_file), "unit-project") == ("old-token", "unit-project")
|
||||
_move_into_place(key_file, _authorized_user_cache_credentials("repaired-key", quota_project="unit-project"))
|
||||
assert await fetch(str(key_file), "unit-project") == ("new-token", "unit-project")
|
||||
assert len(oauth.calls) == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("is_async", [False, True], ids=["sync", "async"])
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_key_file_that_does_not_load_raises_once_google_rejects_the_earlier_key(
|
||||
tmp_path: pathlib.Path, is_async: bool
|
||||
) -> None:
|
||||
vertex: Final = VertexBase()
|
||||
now: Final = datetime(2025, 1, 1)
|
||||
key_file: Final = tmp_path / "vertex-key.json"
|
||||
key_file.write_text(_authorized_user_cache_credentials("old-key", quota_project="unit-project"))
|
||||
old_key_revoked: Final = Event()
|
||||
|
||||
def token_response(request: requests.PreparedRequest) -> tuple[int, dict[str, str], str]:
|
||||
if old_key_revoked.is_set():
|
||||
return 400, {}, json.dumps({"error": "invalid_grant", "error_description": "Token has been expired or revoked."})
|
||||
return _old_or_new_token_response(request)
|
||||
|
||||
fetch: Final = vertex.get_access_token_async if is_async else partial(asyncio.to_thread, vertex.get_access_token)
|
||||
with patch("google.auth._helpers.utcnow", return_value=now) as clock, responses.RequestsMock() as oauth:
|
||||
oauth.add_callback(responses.POST, "https://oauth2.googleapis.com/token", callback=token_response)
|
||||
assert await fetch(str(key_file), "unit-project") == ("old-token", "unit-project")
|
||||
_move_into_place(key_file, '{"type": "authorized_user", "client_id": "half-wri')
|
||||
old_key_revoked.set()
|
||||
clock.return_value = now + timedelta(seconds=7200)
|
||||
with pytest.raises(google.auth.exceptions.RefreshError, match="invalid_grant"):
|
||||
await fetch(str(key_file), "unit-project")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("is_async", [False, True], ids=["sync", "async"])
|
||||
@pytest.mark.parametrize(
|
||||
"google_unavailable",
|
||||
[
|
||||
lambda: (503, {}, json.dumps({"error": "server_error", "error_description": "Service unavailable."})),
|
||||
lambda: _raise(requests.exceptions.ConnectionError("oauth2.googleapis.com is unreachable")),
|
||||
],
|
||||
ids=["503", "unreachable"],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_swapped_key_file_whose_first_token_fetch_fails_is_tried_again_on_the_next_call(
|
||||
tmp_path: pathlib.Path, is_async: bool, google_unavailable: Callable[[], tuple[int, dict[str, str], str]]
|
||||
) -> None:
|
||||
vertex: Final = VertexBase()
|
||||
key_file: Final = tmp_path / "vertex-key.json"
|
||||
key_file.write_text(_authorized_user_cache_credentials("old-key", quota_project="unit-project"))
|
||||
google_down: Final = Event()
|
||||
|
||||
def token_response(request: requests.PreparedRequest) -> tuple[int, dict[str, str], str]:
|
||||
return google_unavailable() if google_down.is_set() else _old_or_new_token_response(request)
|
||||
|
||||
fetch: Final = vertex.get_access_token_async if is_async else partial(asyncio.to_thread, vertex.get_access_token)
|
||||
with patch("google.auth._exponential_backoff.time"), responses.RequestsMock() as oauth:
|
||||
oauth.add_callback(responses.POST, "https://oauth2.googleapis.com/token", callback=token_response)
|
||||
assert await fetch(str(key_file), "unit-project") == ("old-token", "unit-project")
|
||||
_move_into_place(key_file, _authorized_user_cache_credentials("new-key", quota_project="unit-project"))
|
||||
google_down.set()
|
||||
with pytest.raises((google.auth.exceptions.RefreshError, google.auth.exceptions.TransportError)):
|
||||
await fetch(str(key_file), "unit-project")
|
||||
google_down.clear()
|
||||
assert await fetch(str(key_file), "unit-project") == ("new-token", "unit-project")
|
||||
assert await fetch(str(key_file), "unit-project") == ("new-token", "unit-project")
|
||||
|
||||
|
||||
def _raise(error: Exception) -> NoReturn:
|
||||
raise error
|
||||
|
||||
|
||||
def _authorized_user_cache_credentials(identity: str, quota_project: str | None = None) -> str:
|
||||
return json.dumps(
|
||||
{
|
||||
"type": "authorized_user",
|
||||
"client_id": identity,
|
||||
"client_secret": "synthetic-secret",
|
||||
"refresh_token": identity,
|
||||
"quota_project_id": identity,
|
||||
"quota_project_id": quota_project or identity,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -7,13 +7,24 @@ executor, and it must never leak into litellm_params/kwargs where logging would
|
|||
model_dump() it (the #19550 serialization trap).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import pathlib
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Mapping
|
||||
from typing import Final
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import requests
|
||||
import requests_mock as requests_mock_lib
|
||||
import responses
|
||||
import respx
|
||||
|
||||
import litellm
|
||||
import litellm.vector_stores.main as vector_stores_main
|
||||
|
|
@ -384,3 +395,156 @@ async def test_retrieving_a_missing_openai_vector_store_raises_not_found(is_asyn
|
|||
|
||||
assert caught.value.status_code == 404
|
||||
assert "No vector store found with id 'vs_missing'." in caught.value.message
|
||||
|
||||
|
||||
GOOGLE_TOKEN_URL: Final = "https://oauth2.googleapis.com/token"
|
||||
|
||||
|
||||
def _authorized_user_credentials(refresh_token: str) -> str:
|
||||
return json.dumps(
|
||||
{
|
||||
"type": "authorized_user",
|
||||
"client_id": "unit-client-id",
|
||||
"client_secret": "unit-client-secret",
|
||||
"refresh_token": refresh_token,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _search_rag_store(api_base: str, vertex_credentials: str):
|
||||
return litellm.vector_stores.asearch(
|
||||
query="what is LiteLLM?",
|
||||
vector_store_id="unit-store",
|
||||
custom_llm_provider="vertex_ai/rag_api",
|
||||
api_base=api_base,
|
||||
vertex_project="unit-project",
|
||||
vertex_location="us-central1",
|
||||
vertex_credentials=vertex_credentials,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("api_type", "search_method", "search_response"),
|
||||
(
|
||||
("search_api", ":search", {"results": []}),
|
||||
("rag_api", ":retrieveContexts", {"contexts": {"contexts": []}}),
|
||||
),
|
||||
)
|
||||
async def test_concurrent_vertex_searches_with_the_same_credentials_fetch_one_google_token(
|
||||
api_type: str,
|
||||
search_method: str,
|
||||
search_response: dict[str, object],
|
||||
respx_mock: respx.MockRouter,
|
||||
requests_mock: requests_mock_lib.Mocker,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
credentials: Final = _authorized_user_credentials(f"unit-refresh-{uuid.uuid4().hex}")
|
||||
|
||||
def slow_token_response(
|
||||
request: requests_mock_lib.Request, context: requests_mock_lib.Context
|
||||
) -> dict[str, object]:
|
||||
time.sleep(0.2)
|
||||
return {"access_token": "ya29.unit-token", "expires_in": 3600, "token_type": "Bearer"}
|
||||
|
||||
token_route: Final = requests_mock.post(GOOGLE_TOKEN_URL, json=slow_token_response)
|
||||
search_route: Final = respx_mock.post(f"https://vertex.test/{api_type}{search_method}").mock(
|
||||
return_value=httpx.Response(200, json=search_response)
|
||||
)
|
||||
|
||||
await asyncio.gather(
|
||||
*(
|
||||
litellm.vector_stores.asearch(
|
||||
query="what is LiteLLM?",
|
||||
vector_store_id="unit-store",
|
||||
custom_llm_provider=f"vertex_ai/{api_type}",
|
||||
api_base=f"https://vertex.test/{api_type}",
|
||||
vertex_project="unit-project",
|
||||
vertex_location="us-central1",
|
||||
vertex_credentials=credentials,
|
||||
)
|
||||
for _ in range(5)
|
||||
)
|
||||
)
|
||||
|
||||
assert search_route.call_count == 5
|
||||
assert token_route.call_count == 1
|
||||
assert search_route.calls.last.request.headers["authorization"] == "Bearer ya29.unit-token"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_slow_google_token_fetch_for_one_rag_store_does_not_hold_up_another(
|
||||
respx_mock: respx.MockRouter,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
slow_fetch_started: Final = threading.Event()
|
||||
other_store_searched: Final = threading.Event()
|
||||
|
||||
def access_token_for(form: str) -> str:
|
||||
if "slow-store" not in form:
|
||||
return "ya29.fast"
|
||||
slow_fetch_started.set()
|
||||
return f"ya29.slow-waited-{other_store_searched.wait(timeout=5)}"
|
||||
|
||||
def token_response(request: requests.PreparedRequest) -> tuple[int, dict[str, str], str]:
|
||||
form: Final = request.body.decode() if isinstance(request.body, bytes) else str(request.body)
|
||||
token: Final = {"access_token": access_token_for(form), "expires_in": 3600, "token_type": "Bearer"}
|
||||
return 200, {}, json.dumps(token)
|
||||
|
||||
slow_route: Final = respx_mock.post("https://vertex.test/slow:retrieveContexts").mock(
|
||||
return_value=httpx.Response(200, json={"contexts": {"contexts": []}})
|
||||
)
|
||||
respx_mock.post("https://vertex.test/fast:retrieveContexts").mock(
|
||||
return_value=httpx.Response(200, json={"contexts": {"contexts": []}})
|
||||
)
|
||||
|
||||
with responses.RequestsMock() as google:
|
||||
google.add_callback(responses.POST, GOOGLE_TOKEN_URL, callback=token_response)
|
||||
slow_search: Final = asyncio.ensure_future(
|
||||
_search_rag_store("https://vertex.test/slow", _authorized_user_credentials(f"slow-store-{uuid.uuid4().hex}"))
|
||||
)
|
||||
assert await asyncio.to_thread(slow_fetch_started.wait, 5)
|
||||
await _search_rag_store("https://vertex.test/fast", _authorized_user_credentials(f"fast-store-{uuid.uuid4().hex}"))
|
||||
other_store_searched.set()
|
||||
await slow_search
|
||||
|
||||
assert slow_route.calls.last.request.headers["authorization"] == "Bearer ya29.slow-waited-True"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_rag_store_reloads_a_rotated_key_file_when_google_rejects_the_old_key(
|
||||
tmp_path: pathlib.Path,
|
||||
respx_mock: respx.MockRouter,
|
||||
requests_mock: requests_mock_lib.Mocker,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
key_file: Final = tmp_path / "vertex-key.json"
|
||||
key_file.write_text(_authorized_user_credentials(f"old-key-{uuid.uuid4().hex}"))
|
||||
old_key_deleted: Final = threading.Event()
|
||||
|
||||
def token_response(
|
||||
request: requests_mock_lib.Request, context: requests_mock_lib.Context
|
||||
) -> dict[str, object]:
|
||||
is_old_key: Final = "old-key" in request.text
|
||||
if is_old_key and old_key_deleted.is_set():
|
||||
context.status_code = 400
|
||||
return {"error": "invalid_grant", "error_description": "Token has been expired or revoked."}
|
||||
return {"access_token": "ya29.old" if is_old_key else "ya29.new", "expires_in": 0, "token_type": "Bearer"}
|
||||
|
||||
requests_mock.post(GOOGLE_TOKEN_URL, json=token_response)
|
||||
search_route: Final = respx_mock.post("https://vertex.test/rotated:retrieveContexts").mock(
|
||||
return_value=httpx.Response(200, json={"contexts": {"contexts": []}})
|
||||
)
|
||||
|
||||
await _search_rag_store("https://vertex.test/rotated", str(key_file))
|
||||
key_file.write_text(_authorized_user_credentials(f"new-key-{uuid.uuid4().hex}"))
|
||||
old_key_deleted.set()
|
||||
await _search_rag_store("https://vertex.test/rotated", str(key_file))
|
||||
|
||||
assert [call.request.headers["authorization"] for call in search_route.calls] == [
|
||||
"Bearer ya29.old",
|
||||
"Bearer ya29.new",
|
||||
]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue