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:
mateo-berri 2026-09-08 16:59:34 -07:00
parent 71a4a6e912
commit 0f224dd4ed
9 changed files with 194 additions and 9 deletions

View file

@ -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)

View file

@ -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,

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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