mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-11 22:51:28 +00:00
fix: sign Bedrock guardrail, embedding, and Mantle requests off the event loop
Bedrock Guardrails resolved credentials and signed inline on the loop in its three async paths, Titan embeddings signed each batch item inline in the async loop, and Bedrock Mantle requests slipped past the off-loop gate because BedrockMantleAuthMixin composes a BaseAWSLLM instead of inheriting from it. Introduce the SignsRequestsWithAWS marker that both BaseAWSLLM and the Mantle mixin carry so sign_request_off_loop_if_aws covers Mantle, and move the guardrail and embedding signing into asyncio.to_thread. Every new test fails at the previous tip.
This commit is contained in:
parent
71a4a6e912
commit
0f224dd4ed
9 changed files with 194 additions and 9 deletions
|
|
@ -81,7 +81,11 @@ class AwsAuthError(Exception):
|
|||
super().__init__(self.message) # Call the base class constructor with the parameters it needs
|
||||
|
||||
|
||||
class BaseAWSLLM:
|
||||
class SignsRequestsWithAWS:
|
||||
pass
|
||||
|
||||
|
||||
class BaseAWSLLM(SignsRequestsWithAWS):
|
||||
# Process-wide IAM credential cache (shared across instances — Bedrock passthrough is per-request).
|
||||
# Storage is in-process memory only: no Redis backend unless attached elsewhere. Entry TTL: static
|
||||
# access-key + secret + region use ``_get_default_ttl_for_boto3_credentials`` (~59 minutes); ambient
|
||||
|
|
@ -1701,6 +1705,6 @@ async def sign_request_off_loop_if_aws(
|
|||
*args: _SignParams.args,
|
||||
**kwargs: _SignParams.kwargs, # kwargs-ok: ParamSpec forwarding keeps the wrapped sign_request signature
|
||||
) -> _SignedRequest:
|
||||
if isinstance(provider_config, BaseAWSLLM):
|
||||
if isinstance(provider_config, SignsRequestsWithAWS):
|
||||
return await asyncio.to_thread(sign_request, *args, **kwargs)
|
||||
return sign_request(*args, **kwargs)
|
||||
|
|
|
|||
|
|
@ -357,7 +357,8 @@ class BedrockEmbedding(BaseAWSLLM):
|
|||
if extra_headers is not None:
|
||||
headers = {"Content-Type": "application/json", **extra_headers}
|
||||
|
||||
prepped = self.get_request_headers(
|
||||
prepped = await asyncio.to_thread(
|
||||
self.get_request_headers,
|
||||
credentials=credentials,
|
||||
aws_region_name=aws_region_name,
|
||||
extra_headers=extra_headers,
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ from botocore.exceptions import (
|
|||
ProfileNotFound,
|
||||
)
|
||||
|
||||
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM
|
||||
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM, SignsRequestsWithAWS
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
|
||||
BEDROCK_MANTLE_DEFAULT_REGION: Final = "us-east-1"
|
||||
|
|
@ -55,7 +55,7 @@ def resolve_mantle_region(params: Mapping[str, object]) -> str:
|
|||
)
|
||||
|
||||
|
||||
class BedrockMantleAuthMixin:
|
||||
class BedrockMantleAuthMixin(SignsRequestsWithAWS):
|
||||
_aws_signer: BaseAWSLLM
|
||||
|
||||
@staticmethod
|
||||
|
|
|
|||
|
|
@ -917,7 +917,9 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
|
|||
source,
|
||||
)
|
||||
return BedrockGuardrailResponse()
|
||||
credentials, aws_region_name = self._load_credentials(bearer_token=bedrock_bearer_token(api_key))
|
||||
credentials, aws_region_name = await asyncio.to_thread(
|
||||
self._load_credentials, bearer_token=bedrock_bearer_token(api_key)
|
||||
)
|
||||
allow_chunking: Final = not self._content_uses_contextual_grounding(content)
|
||||
|
||||
completed_chunk_usages: Final[list[BedrockGuardrailUsage]] = [] # mutable-ok: billed-chunk usage accumulator
|
||||
|
|
@ -1178,7 +1180,8 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
|
|||
**base_request_data,
|
||||
"content": content,
|
||||
} # mutable-ok: outbound JSON request body
|
||||
prepared_request: Final = self._prepare_request(
|
||||
prepared_request: Final = await asyncio.to_thread(
|
||||
self._prepare_request,
|
||||
credentials=credentials,
|
||||
data=bedrock_request_data,
|
||||
optional_params=self.optional_params,
|
||||
|
|
@ -1875,10 +1878,13 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
|
|||
return BedrockGuardrailResponse()
|
||||
|
||||
api_key: Final[str | None] = request_data.get("api_key") if request_data else None
|
||||
credentials, aws_region_name = self._load_credentials(bearer_token=bedrock_bearer_token(api_key))
|
||||
credentials, aws_region_name = await asyncio.to_thread(
|
||||
self._load_credentials, bearer_token=bedrock_bearer_token(api_key)
|
||||
)
|
||||
body: Final[dict[str, object]] = {"messages": checks_messages, "checks": self.checks}
|
||||
|
||||
prepared_request: Final = self._prepare_request(
|
||||
prepared_request: Final = await asyncio.to_thread(
|
||||
self._prepare_request,
|
||||
credentials=credentials,
|
||||
data=body,
|
||||
optional_params=self.optional_params,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
import json
|
||||
import asyncio
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
import litellm
|
||||
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
|
||||
from litellm.types.llms.base import HiddenParams
|
||||
from tests.test_litellm.llms.bedrock.event_loop_probe import EventLoopProbe
|
||||
|
||||
# Mock async invoke responses
|
||||
async_invoke_response = {
|
||||
|
|
@ -383,3 +387,34 @@ class TestBedrockAsyncInvokeEmbedding:
|
|||
async_endpoint
|
||||
== "https://bedrock-runtime.us-east-1.amazonaws.com/async-invoke"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_invoke_status_signs_off_the_event_loop(monkeypatch):
|
||||
"""Regression for issue #40165: the GetAsyncInvoke poll is a signed GET, and botocore refreshes
|
||||
expiring credentials inside that signing with a blocking HTTP call, so it must run on a worker
|
||||
thread to keep the loop serving other requests."""
|
||||
from litellm.llms.bedrock.embed.embedding import BedrockEmbedding
|
||||
|
||||
monkeypatch.delenv("AWS_BEARER_TOKEN_BEDROCK", raising=False)
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
litellm.in_memory_llm_clients_cache.flush_cache()
|
||||
embedder = BedrockEmbedding()
|
||||
probe = EventLoopProbe()
|
||||
|
||||
with (
|
||||
patch.object(embedder, "_load_credentials", return_value=(probe.credentials(), "us-east-1")),
|
||||
respx.mock,
|
||||
):
|
||||
route = respx.get(url__regex=r"https://bedrock-runtime\.us-east-1\.amazonaws\.com/async-invoke/.*").mock(
|
||||
return_value=httpx.Response(200, json=async_invoke_status_response)
|
||||
)
|
||||
release = asyncio.create_task(probe.release_refresh_from_the_loop())
|
||||
status = await embedder._get_async_invoke_status(
|
||||
invocation_arn=async_invoke_status_response["invocationArn"], aws_region_name="us-east-1"
|
||||
)
|
||||
await release
|
||||
|
||||
assert status["status"] == "InProgress"
|
||||
assert "Authorization" in route.calls.last.request.headers
|
||||
assert probe.served_during_refresh is True
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
import json
|
||||
import asyncio
|
||||
import os
|
||||
from unittest.mock import Mock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
import httpx
|
||||
|
||||
import litellm
|
||||
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
|
||||
from litellm.llms.bedrock.embed.embedding import BedrockEmbedding
|
||||
from tests.test_litellm.llms.bedrock.event_loop_probe import EventLoopProbe
|
||||
|
||||
# Mock responses for different embedding models
|
||||
titan_embedding_response = {"embedding": [0.1, 0.2, 0.3], "inputTextTokenCount": 10}
|
||||
|
|
@ -1059,3 +1064,40 @@ def test_bedrock_embedding_bearer_token_never_runs_the_sigv4_credential_chain(mo
|
|||
|
||||
assert response.data[0]["embedding"] == titan_embedding_response["embedding"]
|
||||
assert mock_post.call_args.kwargs["headers"]["Authorization"] == "Bearer env-bearer-token-12345"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_single_func_embeddings_signs_off_the_event_loop(monkeypatch):
|
||||
"""Regression for issue #40165: Titan, Nova, and TwelveLabs embeddings sign one SigV4 request per
|
||||
input, and botocore refreshes expiring credentials inside that signing with a blocking HTTP call,
|
||||
so each signing must run on a worker thread to keep the loop serving other requests."""
|
||||
monkeypatch.delenv("AWS_BEARER_TOKEN_BEDROCK", raising=False)
|
||||
probe = EventLoopProbe()
|
||||
client = MagicMock()
|
||||
client.__class__ = AsyncHTTPHandler
|
||||
client.post = AsyncMock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json=titan_embedding_response,
|
||||
request=httpx.Request("POST", "https://bedrock-runtime.us-west-2.amazonaws.com"),
|
||||
)
|
||||
)
|
||||
|
||||
release = asyncio.create_task(probe.release_refresh_from_the_loop())
|
||||
response = await BedrockEmbedding()._async_single_func_embeddings(
|
||||
client=client,
|
||||
timeout=None,
|
||||
batch_data=[{"inputText": test_input}],
|
||||
credentials=probe.credentials(),
|
||||
extra_headers=None,
|
||||
endpoint_url="https://bedrock-runtime.us-west-2.amazonaws.com/model/amazon.titan-embed-text-v1/invoke",
|
||||
aws_region_name="us-west-2",
|
||||
model="amazon.titan-embed-text-v1",
|
||||
logging_obj=MagicMock(),
|
||||
provider="amazon",
|
||||
)
|
||||
await release
|
||||
|
||||
assert response.data[0]["embedding"] == titan_embedding_response["embedding"]
|
||||
assert "Authorization" in client.post.call_args.kwargs["headers"]
|
||||
assert probe.served_during_refresh is True
|
||||
|
|
|
|||
|
|
@ -6,15 +6,20 @@ API docs: https://docs.aws.amazon.com/bedrock/latest/userguide/bedrock-mantle.ht
|
|||
"""
|
||||
|
||||
import json
|
||||
import asyncio
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from botocore.auth import SigV4Auth
|
||||
from botocore.awsrequest import AWSRequest
|
||||
|
||||
import litellm
|
||||
from litellm.llms.bedrock_mantle.chat.transformation import BedrockMantleChatConfig
|
||||
from litellm.llms.bedrock.base_aws_llm import sign_request_off_loop_if_aws
|
||||
from litellm.types.utils import LlmProviders
|
||||
from tests.test_litellm.llms.bedrock.event_loop_probe import EventLoopProbe
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -710,3 +715,26 @@ def test_gemma_4_models_register_under_bedrock_mantle(local_cost_map, model_id):
|
|||
resolved_model, provider, _, _ = litellm.get_llm_provider(full_model_name)
|
||||
assert provider == "bedrock_mantle"
|
||||
assert resolved_model == model_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mantle_signing_runs_off_the_event_loop():
|
||||
"""Regression for issue #40165: Mantle signs with SigV4 through a composed BaseAWSLLM, so the
|
||||
off-loop gate must recognise it too, or its credential refresh blocks the loop like Bedrock's did."""
|
||||
probe = EventLoopProbe()
|
||||
|
||||
def sign(headers: dict[str, str]) -> dict[str, str]:
|
||||
request = AWSRequest(
|
||||
method="POST", url="https://bedrock-mantle.us-east-1.api.aws/v1/responses", data="{}", headers=headers
|
||||
)
|
||||
SigV4Auth(probe.credentials(), "bedrock", "us-east-1").add_auth(request)
|
||||
return dict(request.headers)
|
||||
|
||||
release = asyncio.create_task(probe.release_refresh_from_the_loop())
|
||||
signed = await sign_request_off_loop_if_aws(
|
||||
BedrockMantleChatConfig(), sign, headers={"Content-Type": "application/json"}
|
||||
)
|
||||
await release
|
||||
|
||||
assert "Authorization" in signed
|
||||
assert probe.served_during_refresh is True
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ Unit tests for Bedrock Guardrails
|
|||
"""
|
||||
|
||||
import json
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
|
|
@ -28,6 +30,7 @@ from litellm.types.proxy.guardrails.guardrail_hooks.bedrock_guardrails import (
|
|||
BedrockTextContent,
|
||||
)
|
||||
from litellm.types.utils import CallTypes, ModelResponse
|
||||
from tests.test_litellm.llms.bedrock.event_loop_probe import EventLoopProbe
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -5842,3 +5845,36 @@ async def test_bearer_token_never_runs_the_sigv4_credential_chain(monkeypatch):
|
|||
|
||||
assert response["action"] == "NONE"
|
||||
assert mock_post.call_args.kwargs["headers"]["Authorization"] == "Bearer env-bearer-token-12345"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_guardrail_signs_off_the_event_loop(monkeypatch):
|
||||
"""Regression for issue #40165: the ApplyGuardrail request is signed with SigV4, and botocore
|
||||
refreshes expiring credentials inside that signing with a blocking HTTP call, so it must run
|
||||
on a worker thread to keep the loop serving other requests."""
|
||||
monkeypatch.delenv("AWS_BEARER_TOKEN_BEDROCK", raising=False)
|
||||
guardrail = BedrockGuardrail(guardrailIdentifier="test-guardrail", guardrailVersion="DRAFT")
|
||||
probe = EventLoopProbe()
|
||||
allowed = httpx.Response(
|
||||
200,
|
||||
json={"action": "NONE", "outputs": [], "assessments": []},
|
||||
request=httpx.Request("POST", "https://bedrock-runtime.us-east-1.amazonaws.com"),
|
||||
)
|
||||
|
||||
with patch.object(guardrail.async_handler, "post", new=AsyncMock(return_value=allowed)):
|
||||
release = asyncio.create_task(probe.release_refresh_from_the_loop())
|
||||
response = await guardrail._post_apply_guardrail_content(
|
||||
content=[{"text": {"text": "hello"}}],
|
||||
base_request_data={"source": "INPUT"},
|
||||
credentials=probe.credentials(),
|
||||
aws_region_name="us-east-1",
|
||||
api_key=None,
|
||||
request_data={},
|
||||
event_type=GuardrailEventHooks.pre_call,
|
||||
start_time=datetime.now(timezone.utc),
|
||||
completed_chunk_usages=[],
|
||||
)
|
||||
await release
|
||||
|
||||
assert response["action"] == "NONE"
|
||||
assert probe.served_during_refresh is True
|
||||
|
|
|
|||
|
|
@ -5,10 +5,12 @@ All Bedrock HTTP calls are mocked; no real AWS calls are made.
|
|||
"""
|
||||
|
||||
import json
|
||||
import asyncio
|
||||
import logging
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import httpx
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
|
|
@ -21,6 +23,7 @@ from litellm.types.proxy.guardrails.guardrail_hooks.bedrock_guardrails import (
|
|||
BedrockGuardrailResponse,
|
||||
)
|
||||
from litellm.types.utils import Choices, Message, ModelResponse
|
||||
from tests.test_litellm.llms.bedrock.event_loop_probe import EventLoopProbe
|
||||
|
||||
CONTENT_FILTER_CHECKS = {"contentFilter": {"categories": [{"category": "VIOLENCE"}]}}
|
||||
|
||||
|
|
@ -861,3 +864,33 @@ async def test_checks_bearer_token_never_runs_the_sigv4_credential_chain(monkeyp
|
|||
{"check": "contentFilter", "category": "VIOLENCE", "severityScore": 0.8}
|
||||
]
|
||||
assert post.call_args.kwargs["headers"]["Authorization"] == "Bearer env-bearer-token-12345"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoke_guardrail_checks_signs_off_the_event_loop(monkeypatch):
|
||||
"""Regression for issue #40165: the checks request is signed with SigV4, and botocore refreshes
|
||||
expiring credentials inside that signing with a blocking HTTP call, so it must run on a worker
|
||||
thread to keep the loop serving other requests."""
|
||||
monkeypatch.delenv("AWS_BEARER_TOKEN_BEDROCK", raising=False)
|
||||
g = BedrockGuardrail(checks=CONTENT_FILTER_CHECKS, content_filter_threshold=0.5)
|
||||
probe = EventLoopProbe()
|
||||
allowed = httpx.Response(
|
||||
200,
|
||||
json={"results": {"contentFilter": {"results": [{"category": "VIOLENCE", "severityScore": 0.1}]}}},
|
||||
request=httpx.Request("POST", "https://bedrock-runtime.us-east-1.amazonaws.com"),
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(g, "_load_credentials", return_value=(probe.credentials(), "us-east-1")),
|
||||
patch.object(g.async_handler, "post", new=AsyncMock(return_value=allowed)),
|
||||
):
|
||||
release = asyncio.create_task(probe.release_refresh_from_the_loop())
|
||||
response = await g.make_bedrock_api_request(
|
||||
source="INPUT",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
request_data={"messages": []},
|
||||
)
|
||||
await release
|
||||
|
||||
assert response == BedrockGuardrailResponse()
|
||||
assert probe.served_during_refresh is True
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue