fix(caching): answer 400 for a non-list /cache/delete keys and accept null Stability lists

Return pydantic errors without the caller's input on a bad /cache/delete body, let the Bedrock Stability
image edit schema take null finish_reasons and images like the old reads did, declare user_id and user_role
on the plugin session claim, wrap the retyped Vertex fields in ReadOnly, and drop unused Guardrails AI types
This commit is contained in:
mateo-berri 2026-10-28 18:16:28 -07:00
parent ea3d8fc78c
commit 202746255a
7 changed files with 54 additions and 29 deletions

View file

@ -50,8 +50,8 @@ else:
@with_config(ConfigDict(extra="allow", strict=True))
class _StabilityResponse(TypedDict):
finish_reasons: ReadOnly[NotRequired[Sequence[object]]]
images: ReadOnly[NotRequired[Sequence[str | None]]]
finish_reasons: ReadOnly[NotRequired[Sequence[object] | None]]
images: ReadOnly[NotRequired[Sequence[str | None] | None]]
_STABILITY_RESPONSE: Final = TypeAdapter(_StabilityResponse)

View file

@ -2,7 +2,7 @@ from collections.abc import Mapping
from typing import Final
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import ConfigDict, TypeAdapter, with_config
from pydantic import ConfigDict, TypeAdapter, ValidationError, with_config
from typing_extensions import NotRequired, ReadOnly, TypedDict
import litellm
@ -172,6 +172,8 @@ async def cache_delete(request: Request):
status_code=500,
detail=f"Cache type {litellm.cache.type} does not support deleting a key. only `redis` is supported",
)
except ValidationError as e:
raise HTTPException(status_code=400, detail=e.errors(include_url=False, include_input=False))
except Exception as e:
raise HTTPException(
status_code=500,

View file

@ -8,12 +8,11 @@
import json
import os
from collections.abc import Sequence
from typing import TYPE_CHECKING, Final, Literal, TypedDict
from typing import TYPE_CHECKING, Final, Literal
from fastapi import HTTPException
from pydantic import ConfigDict, TypeAdapter, with_config
from typing_extensions import NotRequired, ReadOnly
from typing_extensions import TypedDict as _ValidatedTypedDict
from typing_extensions import NotRequired, ReadOnly, TypedDict
import litellm
from litellm._logging import verbose_proxy_logger
@ -38,26 +37,13 @@ class GuardrailsAIResponse(TypedDict):
validationPassed: bool
class InferenceData(TypedDict):
name: str
shape: list[int]
data: list
datatype: str
class GuardrailsAIResponsePreCall(TypedDict):
modelname: str
modelversion: str
outputs: list[InferenceData]
@with_config(ConfigDict(extra="allow", strict=True))
class _PreCallOutput(_ValidatedTypedDict):
class _PreCallOutput(TypedDict):
data: ReadOnly[NotRequired[Sequence[str]]]
@with_config(ConfigDict(extra="allow", strict=True))
class _PreCallResponse(_ValidatedTypedDict):
class _PreCallResponse(TypedDict):
outputs: ReadOnly[NotRequired[Sequence[_PreCallOutput]]]

View file

@ -45,6 +45,8 @@ router: Final = APIRouter()
@with_config(ConfigDict(extra="allow", strict=True))
class _PluginSessionClaim(TypedDict):
plugin: NotRequired[ReadOnly[object]]
user_id: NotRequired[ReadOnly[str]]
user_role: NotRequired[ReadOnly[str]]
exp: NotRequired[ReadOnly[int | float | str]]

View file

@ -140,7 +140,7 @@ class Schema(TypedDict, total=False):
title: str
description: str
nullable: bool
default: object
default: ReadOnly[object]
items: "Schema"
minItems: str
maxItems: str
@ -155,7 +155,7 @@ class Schema(TypedDict, total=False):
minLength: str
maxLength: str
pattern: str
example: object
example: ReadOnly[object]
anyOf: list["Schema"]
@ -794,9 +794,9 @@ class VertexVideoGenerationResponse(TypedDict, total=False):
name: str
done: bool
response: dict[str, object]
metadata: dict[str, object]
error: dict[str, object]
response: ReadOnly[dict[str, object]]
metadata: ReadOnly[dict[str, object]]
error: ReadOnly[dict[str, object]]
VERTEX_CREDENTIALS_TYPES = str | dict[str, str]

View file

@ -1,3 +1,8 @@
from unittest.mock import MagicMock
import httpx
import pytest
from litellm.llms.bedrock.image_edit.stability_transformation import BedrockStabilityImageEditConfig
@ -12,3 +17,31 @@ 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

@ -70,10 +70,12 @@ def test_cache_delete_passes_string_and_numeric_keys_to_the_cache(mock_redis_del
mock_redis_delete.delete_cache_keys.assert_awaited_once_with(keys=keys)
def test_cache_delete_rejects_keys_that_are_not_a_list(mock_redis_delete):
response = client.post("/cache/delete", headers={"Authorization": "Bearer sk-9876"}, json={"keys": "abc"})
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"})
assert response.status_code == 500
assert response.status_code == 400
assert response.json()["detail"][0]["loc"] == ["keys"]
assert "sk-secret-key" not in response.text
mock_redis_delete.delete_cache_keys.assert_not_awaited()