mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-11 22:51:28 +00:00
fix(vertex_ai): clear cached Vertex OAuth credentials on HTTP 401 for Anthropic /messages
- Add VertexBase.invalidate_credentials; remove (creds, project_id) and (creds, None) when project_id is set (#23512). - async_anthropic_messages_handler: on httpx 401 for vertex_ai, invalidate before _handle_error so retries can load_auth/refresh. - Use module-level httpx in invalidation helper (no redundant inline import). - Unit tests in test_vertex_llm_base.py and test_llm_http_handler.py (mocked). Fixes #23512 Made-with: Cursor
This commit is contained in:
parent
1e10e5cc8f
commit
ea08de2a74
4 changed files with 254 additions and 1 deletions
|
|
@ -1815,6 +1815,54 @@ class BaseLLMHTTPHandler:
|
|||
logging_obj=logging_obj,
|
||||
)
|
||||
|
||||
def _invalidate_vertex_credentials_on_anthropic_http_error(
|
||||
self,
|
||||
e: Exception,
|
||||
anthropic_messages_provider_config: BaseAnthropicMessagesConfig,
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
custom_llm_provider: str,
|
||||
) -> None:
|
||||
"""
|
||||
If Vertex AI returns 401 on Anthropic /messages, clear cached OAuth tokens
|
||||
on the provider config so a router retry (or the next request) refreshes them.
|
||||
"""
|
||||
if custom_llm_provider != "vertex_ai":
|
||||
return
|
||||
status: Optional[int] = None
|
||||
if isinstance(e, httpx.HTTPStatusError) and e.response is not None:
|
||||
status = e.response.status_code
|
||||
else:
|
||||
sc = getattr(e, "status_code", None)
|
||||
if sc is not None:
|
||||
try:
|
||||
status = int(sc)
|
||||
except (TypeError, ValueError):
|
||||
status = None
|
||||
if status != 401:
|
||||
return
|
||||
|
||||
from litellm.llms.vertex_ai.vertex_llm_base import VertexBase
|
||||
|
||||
if not isinstance(anthropic_messages_provider_config, VertexBase):
|
||||
return
|
||||
|
||||
lp: Dict[str, Any]
|
||||
if isinstance(litellm_params, dict):
|
||||
lp = dict(litellm_params)
|
||||
else:
|
||||
model_dump = getattr(litellm_params, "model_dump", None)
|
||||
if callable(model_dump):
|
||||
lp = model_dump(exclude_none=False)
|
||||
else:
|
||||
lp = dict(litellm_params) # type: ignore[arg-type]
|
||||
|
||||
creds = VertexBase.safe_get_vertex_ai_credentials(lp)
|
||||
project = VertexBase.safe_get_vertex_ai_project(lp)
|
||||
anthropic_messages_provider_config.invalidate_credentials(
|
||||
credentials=creds,
|
||||
project_id=project,
|
||||
)
|
||||
|
||||
async def async_anthropic_messages_handler(
|
||||
self,
|
||||
model: str,
|
||||
|
|
@ -1964,6 +2012,12 @@ class BaseLLMHTTPHandler:
|
|||
)
|
||||
response.raise_for_status()
|
||||
except Exception as e:
|
||||
self._invalidate_vertex_credentials_on_anthropic_http_error(
|
||||
e=e,
|
||||
anthropic_messages_provider_config=anthropic_messages_provider_config,
|
||||
litellm_params=litellm_params,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
)
|
||||
raise self._handle_error(
|
||||
e=e, provider_config=anthropic_messages_provider_config
|
||||
)
|
||||
|
|
|
|||
|
|
@ -541,6 +541,40 @@ class VertexBase:
|
|||
# Re-raise the original error for better context
|
||||
raise error
|
||||
|
||||
def invalidate_credentials(
|
||||
self,
|
||||
credentials: Optional[VERTEX_CREDENTIALS_TYPES],
|
||||
project_id: Optional[str],
|
||||
) -> None:
|
||||
"""
|
||||
Drop cached OAuth credentials for this (credentials, project_id) pair.
|
||||
|
||||
Used when the Vertex / Google API returns 401 so the next get_access_token()
|
||||
reloads credentials instead of reusing an access token Google rejects while
|
||||
google-auth may still consider non-expired (see #23512).
|
||||
"""
|
||||
cache_credentials = (
|
||||
json.dumps(credentials) if isinstance(credentials, dict) else credentials
|
||||
)
|
||||
key = (cache_credentials, project_id)
|
||||
if key in self._credentials_project_mapping:
|
||||
verbose_logger.debug(
|
||||
"Invalidating cached Vertex credentials for project_id=%s (API 401)",
|
||||
project_id,
|
||||
)
|
||||
del self._credentials_project_mapping[key]
|
||||
# get_access_token may register (creds, None) then duplicate under resolved
|
||||
# project_id; clear the None entry when invalidating a resolved project.
|
||||
if project_id is not None:
|
||||
none_key = (cache_credentials, None)
|
||||
if none_key in self._credentials_project_mapping:
|
||||
verbose_logger.debug(
|
||||
"Invalidating cached Vertex credentials for project_id=None "
|
||||
"(paired with resolved project_id=%s)",
|
||||
project_id,
|
||||
)
|
||||
del self._credentials_project_mapping[none_key]
|
||||
|
||||
def get_access_token(
|
||||
self,
|
||||
credentials: Optional[VERTEX_CREDENTIALS_TYPES],
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import os
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
sys.path.insert(
|
||||
|
|
@ -288,3 +289,104 @@ async def test_async_anthropic_messages_handler_header_priority():
|
|||
assert captured_headers["X-Forwarded-Only"] == "keep"
|
||||
assert captured_headers["X-Extra-Only"] == "also-keep"
|
||||
assert captured_headers["X-Provider-Only"] == "keep-this-too"
|
||||
|
||||
|
||||
def _http_401_error() -> httpx.HTTPStatusError:
|
||||
req = httpx.Request("POST", "https://aiplatform.googleapis.com/v1/fake")
|
||||
resp = httpx.Response(401, request=req, json={"error": {"code": 401}})
|
||||
return httpx.HTTPStatusError("unauthorized", request=req, response=resp)
|
||||
|
||||
|
||||
def test_invalidate_vertex_on_anthropic_http_error_skips_non_vertex_provider():
|
||||
"""401 must not call Vertex cache invalidation when custom_llm_provider is not vertex_ai."""
|
||||
handler = BaseLLMHTTPHandler()
|
||||
mock_config = MagicMock()
|
||||
handler._invalidate_vertex_credentials_on_anthropic_http_error(
|
||||
e=_http_401_error(),
|
||||
anthropic_messages_provider_config=mock_config,
|
||||
litellm_params=GenericLiteLLMParams(),
|
||||
custom_llm_provider="anthropic",
|
||||
)
|
||||
mock_config.invalidate_credentials.assert_not_called()
|
||||
|
||||
|
||||
def test_invalidate_vertex_on_anthropic_http_error_skips_non_vertex_base_config():
|
||||
"""Non-VertexBase provider configs are ignored even for vertex_ai (defensive)."""
|
||||
handler = BaseLLMHTTPHandler()
|
||||
mock_config = Mock(spec=["invalidate_credentials"])
|
||||
handler._invalidate_vertex_credentials_on_anthropic_http_error(
|
||||
e=_http_401_error(),
|
||||
anthropic_messages_provider_config=mock_config,
|
||||
litellm_params=GenericLiteLLMParams(
|
||||
vertex_project="proj-x",
|
||||
vertex_credentials={"type": "service_account"},
|
||||
),
|
||||
custom_llm_provider="vertex_ai",
|
||||
)
|
||||
mock_config.invalidate_credentials.assert_not_called()
|
||||
|
||||
|
||||
def test_invalidate_vertex_on_anthropic_http_error_skips_non_401():
|
||||
handler = BaseLLMHTTPHandler()
|
||||
from litellm.llms.vertex_ai.vertex_ai_partner_models.anthropic.experimental_pass_through.transformation import (
|
||||
VertexAIPartnerModelsAnthropicMessagesConfig,
|
||||
)
|
||||
|
||||
config = VertexAIPartnerModelsAnthropicMessagesConfig()
|
||||
with patch.object(config, "invalidate_credentials") as mock_inv:
|
||||
req = httpx.Request("POST", "https://example.com")
|
||||
resp = httpx.Response(429, request=req)
|
||||
err = httpx.HTTPStatusError("rate limit", request=req, response=resp)
|
||||
handler._invalidate_vertex_credentials_on_anthropic_http_error(
|
||||
e=err,
|
||||
anthropic_messages_provider_config=config,
|
||||
litellm_params=GenericLiteLLMParams(vertex_project="p"),
|
||||
custom_llm_provider="vertex_ai",
|
||||
)
|
||||
mock_inv.assert_not_called()
|
||||
|
||||
|
||||
def test_invalidate_vertex_on_anthropic_http_error_calls_invalidate_on_401():
|
||||
"""Vertex Anthropic + httpx 401 → invalidate_credentials with safe_get params."""
|
||||
handler = BaseLLMHTTPHandler()
|
||||
from litellm.llms.vertex_ai.vertex_ai_partner_models.anthropic.experimental_pass_through.transformation import (
|
||||
VertexAIPartnerModelsAnthropicMessagesConfig,
|
||||
)
|
||||
|
||||
creds = {"type": "service_account", "project_id": "unit-test-proj"}
|
||||
config = VertexAIPartnerModelsAnthropicMessagesConfig()
|
||||
with patch.object(config, "invalidate_credentials") as mock_inv:
|
||||
handler._invalidate_vertex_credentials_on_anthropic_http_error(
|
||||
e=_http_401_error(),
|
||||
anthropic_messages_provider_config=config,
|
||||
litellm_params=GenericLiteLLMParams(
|
||||
vertex_project="unit-test-proj",
|
||||
vertex_location="global",
|
||||
vertex_credentials=creds,
|
||||
),
|
||||
custom_llm_provider="vertex_ai",
|
||||
)
|
||||
mock_inv.assert_called_once_with(
|
||||
credentials=creds,
|
||||
project_id="unit-test-proj",
|
||||
)
|
||||
|
||||
|
||||
def test_invalidate_vertex_on_anthropic_http_error_401_via_status_code_attr():
|
||||
"""Some transports attach status_code without being httpx.HTTPStatusError."""
|
||||
handler = BaseLLMHTTPHandler()
|
||||
from litellm.llms.vertex_ai.vertex_ai_partner_models.anthropic.experimental_pass_through.transformation import (
|
||||
VertexAIPartnerModelsAnthropicMessagesConfig,
|
||||
)
|
||||
|
||||
config = VertexAIPartnerModelsAnthropicMessagesConfig()
|
||||
err = Exception("wrapped")
|
||||
setattr(err, "status_code", 401)
|
||||
with patch.object(config, "invalidate_credentials") as mock_inv:
|
||||
handler._invalidate_vertex_credentials_on_anthropic_http_error(
|
||||
e=err,
|
||||
anthropic_messages_provider_config=config,
|
||||
litellm_params=GenericLiteLLMParams(vertex_project="p2"),
|
||||
custom_llm_provider="vertex_ai",
|
||||
)
|
||||
mock_inv.assert_called_once_with(credentials=None, project_id="p2")
|
||||
|
|
|
|||
|
|
@ -120,6 +120,69 @@ class TestVertexBase:
|
|||
assert token2 == "token-1"
|
||||
assert project2 == "project-1"
|
||||
|
||||
def test_invalidate_credentials_clears_project_and_none_keys(self):
|
||||
"""
|
||||
After get_access_token(project_id=None), both (creds, None) and (creds, resolved)
|
||||
may exist; invalidate_credentials(project_id=resolved) must remove both (#23512).
|
||||
"""
|
||||
vertex_base = VertexBase()
|
||||
credentials = {"type": "service_account", "project_id": "resolved-proj"}
|
||||
cache_creds = json.dumps(credentials)
|
||||
mock_creds = MagicMock()
|
||||
key_none = (cache_creds, None)
|
||||
key_resolved = (cache_creds, "resolved-proj")
|
||||
vertex_base._credentials_project_mapping[key_none] = (
|
||||
mock_creds,
|
||||
"resolved-proj",
|
||||
)
|
||||
vertex_base._credentials_project_mapping[key_resolved] = (
|
||||
mock_creds,
|
||||
"resolved-proj",
|
||||
)
|
||||
|
||||
vertex_base.invalidate_credentials(
|
||||
credentials=credentials,
|
||||
project_id="resolved-proj",
|
||||
)
|
||||
|
||||
assert key_none not in vertex_base._credentials_project_mapping
|
||||
assert key_resolved not in vertex_base._credentials_project_mapping
|
||||
|
||||
def test_invalidate_credentials_removes_single_cache_entry(self):
|
||||
"""When only (creds, project_id) exists, invalidate removes that entry."""
|
||||
vertex_base = VertexBase()
|
||||
credentials = {"type": "service_account", "project_id": "p-only"}
|
||||
cache_creds = json.dumps(credentials)
|
||||
mock_creds = MagicMock()
|
||||
key = (cache_creds, "p-only")
|
||||
vertex_base._credentials_project_mapping[key] = (mock_creds, "p-only")
|
||||
|
||||
vertex_base.invalidate_credentials(credentials=credentials, project_id="p-only")
|
||||
|
||||
assert key not in vertex_base._credentials_project_mapping
|
||||
|
||||
def test_invalidate_credentials_with_none_project_id(self):
|
||||
"""invalidate_credentials(..., project_id=None) only removes the (creds, None) key."""
|
||||
vertex_base = VertexBase()
|
||||
credentials = {"type": "service_account"}
|
||||
cache_creds = json.dumps(credentials)
|
||||
mock_creds = MagicMock()
|
||||
key_none = (cache_creds, None)
|
||||
key_other = (cache_creds, "other-project")
|
||||
vertex_base._credentials_project_mapping[key_none] = (mock_creds, "x")
|
||||
vertex_base._credentials_project_mapping[key_other] = (mock_creds, "x")
|
||||
|
||||
vertex_base.invalidate_credentials(credentials=credentials, project_id=None)
|
||||
|
||||
assert key_none not in vertex_base._credentials_project_mapping
|
||||
assert key_other in vertex_base._credentials_project_mapping
|
||||
|
||||
def test_invalidate_credentials_noop_when_key_missing(self):
|
||||
vertex_base = VertexBase()
|
||||
credentials = {"type": "service_account", "project_id": "missing"}
|
||||
vertex_base.invalidate_credentials(credentials=credentials, project_id="missing")
|
||||
assert vertex_base._credentials_project_mapping == {}
|
||||
|
||||
@pytest.mark.parametrize("is_async", [True, False], ids=["async", "sync"])
|
||||
@pytest.mark.asyncio
|
||||
async def test_credential_refresh(self, is_async):
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue