refactor(types): leave provider reply parsers for a follow-up with live provider calls

Restores the Bedrock Stability, Bedrock KB, AgentCore, Replicate, Recraft, Aleph Alpha and Guardrails AI parsers to main, since no live provider call could prove the stricter schemas. Also types the cache delete route tests.
This commit is contained in:
mateo-berri 2026-10-28 19:12:00 -07:00
parent 202746255a
commit bd31b30c21
9 changed files with 39 additions and 162 deletions

View file

@ -9,8 +9,6 @@ import json
from collections.abc import AsyncIterator, Mapping
from typing import Final
from pydantic import ConfigDict, TypeAdapter
from litellm._logging import verbose_logger
from litellm.a2a_protocol.providers.bedrock_agentcore.transformation import (
BedrockAgentCoreA2ATransformation,
@ -19,8 +17,6 @@ from litellm.llms.bedrock.base_aws_llm import run_aws_signing
from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
from litellm.types.llms.custom_http import httpxSpecialProvider
_JSON_OBJECT: Final = TypeAdapter(dict[str, object], config=ConfigDict(hide_input_in_errors=True))
class BedrockAgentCoreA2AHandler:
"""
@ -70,7 +66,7 @@ class BedrockAgentCoreA2AHandler:
data=body,
)
response.raise_for_status()
response_data: Final = _JSON_OBJECT.validate_python(response.json(), strict=True)
response_data: Final = response.json()
if "error" in response_data:
verbose_logger.warning("BedrockAgentCore A2A: Agent returned error: %s", response_data["error"])
@ -129,7 +125,7 @@ class BedrockAgentCoreA2AHandler:
"BedrockAgentCore A2A streaming: received JSON instead of SSE, yielding as single event"
)
response_body: Final = await response.aread()
response_data: Final = _JSON_OBJECT.validate_python(json.loads(response_body), strict=True)
response_data: Final = json.loads(response_body)
yield response_data
else:
# SSE stream — parse data: lines

View file

@ -22,13 +22,10 @@ API Reference: https://docs.aws.amazon.com/bedrock/latest/userguide/model-parame
"""
import base64
from collections.abc import Sequence
from typing import TYPE_CHECKING, Any, Final, cast
import httpx
from httpx._types import RequestFiles
from pydantic import ConfigDict, TypeAdapter, with_config
from typing_extensions import NotRequired, ReadOnly, TypedDict
from litellm.llms.base_llm.image_edit.transformation import BaseImageEditConfig
from litellm.llms.bedrock.common_utils import BedrockError
@ -48,15 +45,6 @@ else:
LiteLLMLoggingObj = Any
@with_config(ConfigDict(extra="allow", strict=True))
class _StabilityResponse(TypedDict):
finish_reasons: ReadOnly[NotRequired[Sequence[object] | None]]
images: ReadOnly[NotRequired[Sequence[str | None] | None]]
_STABILITY_RESPONSE: Final = TypeAdapter(_StabilityResponse)
class BedrockStabilityImageEditConfig(BaseImageEditConfig):
"""
Configuration for Bedrock Stability AI image edit.
@ -327,10 +315,8 @@ class BedrockStabilityImageEditConfig(BaseImageEditConfig):
headers=raw_response.headers,
)
payload: Final = _STABILITY_RESPONSE.validate_python(response_data)
# Check finish_reasons
finish_reasons: Final = payload.get("finish_reasons", [])
finish_reasons: Final = response_data.get("finish_reasons", [])
if finish_reasons and finish_reasons[0]:
raise self.get_error_class(
error_message=f"Bedrock Stability error: {finish_reasons[0]}",
@ -343,7 +329,7 @@ class BedrockStabilityImageEditConfig(BaseImageEditConfig):
model_response.data = []
# Extract images from response
images: Final = payload.get("images", [])
images: Final = response_data.get("images", [])
if images:
for image_b64 in images:
if image_b64:

View file

@ -1,11 +1,9 @@
from collections.abc import Mapping, Sequence
from collections.abc import Mapping
from copy import deepcopy
from typing import TYPE_CHECKING, Any, Final, cast
from urllib.parse import urlparse
import httpx
from pydantic import ConfigDict, TypeAdapter, with_config
from typing_extensions import NotRequired, ReadOnly, TypedDict
from litellm._logging import verbose_logger
from litellm.litellm_core_utils.url_utils import encode_url_path_segment, join_api_base
@ -13,6 +11,8 @@ from litellm.llms.base_llm.vector_store.transformation import BaseVectorStoreCon
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM
from litellm.llms.bedrock.common_utils import BedrockError
from litellm.types.integrations.rag.bedrock_knowledgebase import (
BedrockKBContent,
BedrockKBResponse,
BedrockKBRetrievalConfiguration,
BedrockKBRetrievalQuery,
BedrockKBUserContext,
@ -34,26 +34,6 @@ else:
LiteLLMLoggingObj = Any
@with_config(ConfigDict(extra="allow", strict=True))
class _KBContent(TypedDict):
text: ReadOnly[NotRequired[str | None]]
@with_config(ConfigDict(extra="allow", strict=True))
class _KBRetrievalResult(TypedDict):
content: ReadOnly[NotRequired[_KBContent | None]]
score: ReadOnly[NotRequired[int | float | None]]
metadata: ReadOnly[NotRequired[Mapping[str, object] | None]]
@with_config(ConfigDict(extra="allow", strict=True))
class _KBResponse(TypedDict):
retrievalResults: ReadOnly[NotRequired[Sequence[_KBRetrievalResult] | None]]
_KB_RESPONSE: Final = TypeAdapter(_KBResponse)
class BedrockVectorStoreConfig(BaseVectorStoreConfig, BaseAWSLLM):
"""Vector store configuration for AWS Bedrock Knowledge Bases."""
@ -304,7 +284,7 @@ class BedrockVectorStoreConfig(BaseVectorStoreConfig, BaseAWSLLM):
api_key=api_key,
)
def _get_file_id_from_metadata(self, metadata: Mapping[str, Any]) -> str:
def _get_file_id_from_metadata(self, metadata: dict[str, Any]) -> str:
"""
Extract file_id from Bedrock KB metadata.
Uses source URI if available, otherwise generates a fallback ID.
@ -316,7 +296,7 @@ class BedrockVectorStoreConfig(BaseVectorStoreConfig, BaseAWSLLM):
chunk_id: Final = metadata.get("x-amz-bedrock-kb-chunk-id", "unknown") if metadata else "unknown"
return f"bedrock-kb-{chunk_id}"
def _get_filename_from_metadata(self, metadata: Mapping[str, Any]) -> str:
def _get_filename_from_metadata(self, metadata: dict[str, Any]) -> str:
"""
Extract filename from Bedrock KB metadata.
Tries to extract filename from source URI, falls back to domain name or data source ID.
@ -338,7 +318,7 @@ class BedrockVectorStoreConfig(BaseVectorStoreConfig, BaseAWSLLM):
data_source_id: Final = metadata.get("x-amz-bedrock-kb-data-source-id", "unknown") if metadata else "unknown"
return f"bedrock-kb-document-{data_source_id}"
def _get_attributes_from_metadata(self, metadata: Mapping[str, object]) -> dict[str, object]:
def _get_attributes_from_metadata(self, metadata: dict[str, object]) -> dict[str, object]:
"""
Extract all attributes from Bedrock KB metadata.
Returns a copy of the metadata dictionary.
@ -351,10 +331,10 @@ class BedrockVectorStoreConfig(BaseVectorStoreConfig, BaseAWSLLM):
self, response: httpx.Response, litellm_logging_obj: LiteLLMLoggingObj
) -> VectorStoreSearchResponse:
try:
response_data: Final = _KB_RESPONSE.validate_python(response.json())
response_data: Final = BedrockKBResponse(**response.json())
results: Final[list[VectorStoreSearchResult]] = []
for item in response_data.get("retrievalResults", []) or []:
content: _KBContent | None = item.get("content")
content: BedrockKBContent | None = item.get("content")
text = content.get("text") if content else None
if text is None:
continue

View file

@ -5,27 +5,11 @@ from collections.abc import Callable, Mapping
from typing import Final
import httpx
from pydantic import ConfigDict, TypeAdapter, with_config
from typing_extensions import ReadOnly, TypedDict
import litellm
from litellm.utils import Choices, Message, ModelResponse, Usage
@with_config(ConfigDict(extra="allow", strict=True))
class _AlephAlphaCompletion(TypedDict):
completion: ReadOnly[str]
finish_reason: ReadOnly[str | None]
@with_config(ConfigDict(extra="allow", strict=True))
class _AlephAlphaResponse(TypedDict):
completions: ReadOnly[list[_AlephAlphaCompletion]]
_ALEPH_ALPHA_RESPONSE: Final = TypeAdapter(_AlephAlphaResponse)
class AlephAlphaError(Exception):
def __init__(self, status_code, message):
self.status_code = status_code
@ -273,9 +257,8 @@ def completion(
)
else:
try:
validated_response: Final = _ALEPH_ALPHA_RESPONSE.validate_python(completion_response)
choices_list: Final = []
for idx, item in enumerate(validated_response["completions"]):
for idx, item in enumerate(completion_response["completions"]):
if len(item["completion"]) > 0:
message_obj = Message(content=item["completion"])
else:

View file

@ -1,11 +1,8 @@
from collections.abc import Sequence
from io import BufferedReader
from typing import TYPE_CHECKING, Any, Final, cast
import httpx
from httpx._types import RequestFiles
from pydantic import ConfigDict, TypeAdapter, with_config
from typing_extensions import NotRequired, ReadOnly, TypedDict
from litellm.images.utils import ImageEditRequestUtils
from litellm.litellm_core_utils.url_utils import join_api_base
@ -25,21 +22,6 @@ else:
LiteLLMLoggingObj = Any
@with_config(ConfigDict(extra="allow", strict=True))
class _RecraftImageData(TypedDict):
url: ReadOnly[NotRequired[str | None]]
b64_json: ReadOnly[NotRequired[str | None]]
@with_config(ConfigDict(extra="allow", strict=True))
class _RecraftImageEditResponse(TypedDict):
data: ReadOnly[Sequence[_RecraftImageData]]
_JSON_BODY: Final = TypeAdapter(object, config=ConfigDict(hide_input_in_errors=True))
_RECRAFT_IMAGE_EDIT_RESPONSE: Final = TypeAdapter(_RecraftImageEditResponse)
class RecraftImageEditConfig(BaseImageEditConfig):
DEFAULT_BASE_URL: str = "https://external.api.recraft.ai"
IMAGE_EDIT_ENDPOINT: str = "v1/images/imageToImage"
@ -170,14 +152,13 @@ class RecraftImageEditConfig(BaseImageEditConfig):
) -> ImageResponse:
model_response: Final = ImageResponse()
try:
raw_response_data: Final = _JSON_BODY.validate_python(raw_response.json())
response_data: Final = raw_response.json()
except Exception as e:
raise self.get_error_class(
error_message=f"Error transforming image edit response: {e}",
status_code=raw_response.status_code,
headers=raw_response.headers,
)
response_data: Final = _RECRAFT_IMAGE_EDIT_RESPONSE.validate_python(raw_response_data)
if not model_response.data:
model_response.data = []

View file

@ -3,8 +3,7 @@ from collections.abc import Iterable, Mapping
from typing import TYPE_CHECKING, Any, Final
import httpx
from pydantic import ConfigDict, TypeAdapter, with_config
from typing_extensions import NotRequired, ReadOnly, TypedDict
from pydantic import ConfigDict, TypeAdapter
import litellm
from litellm.constants import REPLICATE_MODEL_NAME_WITH_ID_LENGTH
@ -34,20 +33,6 @@ _TEXTS: Final = TypeAdapter(Iterable[str], config=ConfigDict(strict=True, hide_i
_JSON_DICT: Final = TypeAdapter(dict[str, object], config=ConfigDict(hide_input_in_errors=True))
@with_config(ConfigDict(extra="allow", strict=True))
class _ReplicatePredictionUrls(TypedDict):
get: ReadOnly[NotRequired[str | None]]
@with_config(ConfigDict(extra="allow", strict=True))
class _ReplicatePrediction(TypedDict):
urls: ReadOnly[NotRequired[_ReplicatePredictionUrls]]
_JSON_BODY: Final = TypeAdapter(object, config=ConfigDict(hide_input_in_errors=True))
_REPLICATE_PREDICTION: Final = TypeAdapter(_ReplicatePrediction)
class ReplicateConfig(BaseConfig):
"""
Reference: https://replicate.com/meta/llama-2-70b-chat/api
@ -307,11 +292,8 @@ class ReplicateConfig(BaseConfig):
"urls":{"cancel":"https://api.replicate.com/v1/predictions/gqsmqmp1pdrj00cknr08dgmvb4/cancel","get":"https://api.replicate.com/v1/predictions/gqsmqmp1pdrj00cknr08dgmvb4","stream":"https://stream-b.svc.rno2.c.replicate.net/v1/streams/eot4gbydowuin4snhncydwxt57dfwgsc3w3snycx5nid7oef7jga"}
}
"""
response_json: Final = _JSON_BODY.validate_python(response.json())
prediction_urls: Final = _REPLICATE_PREDICTION.validate_python(response_json).get("urls")
prediction_url: Final = (
prediction_urls["get"] if prediction_urls is not None and "get" in prediction_urls else None
)
response_json: Final = response.json()
prediction_url: Final = response_json.get("urls", {}).get("get")
if prediction_url is None:
raise ReplicateError(
status_code=400,

View file

@ -7,12 +7,9 @@
import json
import os
from collections.abc import Sequence
from typing import TYPE_CHECKING, Final, Literal
from typing import TYPE_CHECKING, Final, Literal, TypedDict
from fastapi import HTTPException
from pydantic import ConfigDict, TypeAdapter, with_config
from typing_extensions import NotRequired, ReadOnly, TypedDict
import litellm
from litellm._logging import verbose_proxy_logger
@ -37,17 +34,17 @@ class GuardrailsAIResponse(TypedDict):
validationPassed: bool
@with_config(ConfigDict(extra="allow", strict=True))
class _PreCallOutput(TypedDict):
data: ReadOnly[NotRequired[Sequence[str]]]
class InferenceData(TypedDict):
name: str
shape: list[int]
data: list
datatype: str
@with_config(ConfigDict(extra="allow", strict=True))
class _PreCallResponse(TypedDict):
outputs: ReadOnly[NotRequired[Sequence[_PreCallOutput]]]
_PRE_CALL_RESPONSE: Final = TypeAdapter(_PreCallResponse)
class GuardrailsAIResponsePreCall(TypedDict):
modelname: str
modelversion: str
outputs: list[InferenceData]
class GuardrailsAI(CustomGuardrail):
@ -133,7 +130,7 @@ class GuardrailsAI(CustomGuardrail):
},
)
_json_response: Final = _PRE_CALL_RESPONSE.validate_python(response.json())
_json_response: Final = GuardrailsAIResponsePreCall(**response.json())
response = _json_response.get("outputs", [])[0].get("data", [])[0]
return response

View file

@ -1,8 +1,3 @@
from unittest.mock import MagicMock
import httpx
import pytest
from litellm.llms.bedrock.image_edit.stability_transformation import BedrockStabilityImageEditConfig
@ -17,31 +12,3 @@ def test_transform_image_edit_request_returns_the_json_body_and_no_files():
)
assert request == ({"output_format": "png", "prompt": "add a red hat", "image": "iVBORy1ieXRlcw=="}, {})
@pytest.mark.parametrize(
("body", "expected_images"),
[
({"images": ["aW1hZ2U="], "finish_reasons": [None], "seeds": [42]}, ["aW1hZ2U="]),
({"images": ["aW1hZ2U="], "finish_reasons": None}, ["aW1hZ2U="]),
({"images": None, "finish_reasons": None}, []),
({}, []),
],
)
def test_transform_image_edit_response_returns_each_image_and_tolerates_null_lists(body, expected_images):
response = BedrockStabilityImageEditConfig().transform_image_edit_response(
model="stability.stable-image-inpaint-v1:0",
raw_response=httpx.Response(200, json=body),
logging_obj=MagicMock(),
)
assert [image.b64_json for image in response.data or []] == expected_images
def test_transform_image_edit_response_raises_the_first_finish_reason():
with pytest.raises(Exception, match="Bedrock Stability error: Filter reason: prompt"):
BedrockStabilityImageEditConfig().transform_image_edit_response(
model="stability.stable-image-inpaint-v1:0",
raw_response=httpx.Response(200, json={"images": [None], "finish_reasons": ["Filter reason: prompt"]}),
logging_obj=MagicMock(),
)

View file

@ -1,8 +1,11 @@
import json
from typing import Final
from unittest.mock import MagicMock
import pytest
from fastapi.testclient import TestClient
from pydantic import ValidationError
from pytest_mock import MockerFixture
@ -53,8 +56,8 @@ def mock_redis_failure(mocker):
@pytest.fixture
def mock_redis_delete(mocker):
mock_cache = mocker.MagicMock()
def mock_redis_delete(mocker: MockerFixture) -> MagicMock:
mock_cache: Final = mocker.MagicMock()
mock_cache.type = "redis"
mock_cache.delete_cache_keys = mocker.AsyncMock()
mocker.patch.object(litellm, "cache", mock_cache)
@ -62,16 +65,18 @@ def mock_redis_delete(mocker):
@pytest.mark.parametrize("keys", [["a-key", "b-key"], [12345, 1.5]])
def test_cache_delete_passes_string_and_numeric_keys_to_the_cache(mock_redis_delete, keys):
response = client.post("/cache/delete", headers={"Authorization": "Bearer sk-9876"}, json={"keys": keys})
def test_cache_delete_passes_string_and_numeric_keys_to_the_cache(
mock_redis_delete: MagicMock, keys: list[str | int | float]
) -> None:
response: Final = client.post("/cache/delete", headers={"Authorization": "Bearer sk-9876"}, json={"keys": keys})
assert response.status_code == 200
assert response.json() == {"status": "success"}
mock_redis_delete.delete_cache_keys.assert_awaited_once_with(keys=keys)
def test_cache_delete_rejects_keys_that_are_not_a_list_with_a_400_that_hides_them(mock_redis_delete):
response = client.post("/cache/delete", headers={"Authorization": "Bearer sk-9876"}, json={"keys": "sk-secret-key"})
def test_cache_delete_rejects_keys_that_are_not_a_list_with_a_400_that_hides_them(mock_redis_delete: MagicMock) -> None:
response: Final = client.post("/cache/delete", headers={"Authorization": "Bearer sk-9876"}, json={"keys": "sk-secret-key"})
assert response.status_code == 400
assert response.json()["detail"][0]["loc"] == ["keys"]