litellm/tests/unit/llms/replicate/chat/test_handler.py
Mateo Wang 0ebec123fd
chore(typing): clear 670 basedpyright Any errors by validating provider payloads at the boundary (#45388)
* chore(typing): validate provider payloads at the boundary to clear 464 basedpyright Any errors

Replaces untyped reads of decoded JSON in 48 provider, guardrail and proxy
modules with one pydantic validation at the point the payload enters the
module, so every value read afterwards carries a real type.

reportAny 6111 -> 5663 and reportExplicitAny 1416 -> 1400. No basedpyright,
ruff-strict or type-discipline rule is higher repo-wide or in any single
file, and no cast, ignore or suppression marker was added.

* chore(typing): replace 47 Any annotations that every caller already satisfies with object

Each annotation here is a parameter or return type whose value is only
stored, compared, logged or forwarded, so object describes it exactly and
no function body changes. A site was kept only if no basedpyright,
ruff-strict or type-discipline rule rose in any file that references it.

reportExplicitAny 1400 -> 1353 and reportAny 5663 -> 5655.

* chore(typing): type upstream responses and loose annotations across providers, proxy and integrations

Second pass of the Any reduction. Decoded upstream JSON is validated once at
the provider boundary and read through typed views afterwards, annotations
that every caller already satisfies move from Any to a concrete type, and the
nine search transformations validate each result item on its own so error
text no longer carries the item index.

No cast, no type: ignore, no suppression marker and no budget file edit

* ci(unit): run the brave, google_pse, nlp_cloud and opensandbox tests in the providers shard
2026-10-13 15:35:23 -07:00

206 lines
7.4 KiB
Python

import httpx
import pytest
import respx
from pydantic import ValidationError
import litellm
from litellm.exceptions import MidStreamFallbackError
from litellm.llms.custom_httpx.http_handler import HTTPHandler
from litellm.llms.replicate.chat.handler import handle_prediction_response_streaming
from litellm.llms.replicate.common_utils import ReplicateError
POLL_URL = "https://api.replicate.com/v1/predictions/test-prediction"
CREATE_URL = "https://api.replicate.com/v1/models/meta/meta-llama-3-8b-instruct/predictions"
MANY_TOKENS = [f"token-{index} " for index in range(500)]
@pytest.fixture(autouse=True)
def _poll_without_waiting(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr("litellm.llms.replicate.chat.handler.REPLICATE_POLLING_DELAY_SECONDS", 0)
def _poll(*bodies: object) -> list[dict[str, object]]:
with respx.mock:
respx.get(POLL_URL).mock(side_effect=[httpx.Response(200, json=body) for body in bodies])
return list(
handle_prediction_response_streaming(
POLL_URL,
"replicate-test-key",
lambda _message: None,
headers={"Authorization": "Bearer replicate-test-key"},
http_client=HTTPHandler(),
)
)
def test_streaming_poll_yields_only_the_text_added_since_the_previous_poll():
chunks = _poll(
{"status": "starting"},
{"status": "processing", "output": ["Hel"]},
{"status": "processing", "output": ["Hel", "lo"]},
{"status": "succeeded", "output": ["Hel", "lo", ", world"], "metrics": {"predict_time": 1.5}},
)
assert chunks == [
{"output": "Hel", "status": "processing"},
{"output": "lo", "status": "processing"},
{"output": ", world", "status": "succeeded"},
]
@pytest.mark.parametrize(
("output", "text"),
[
(["Hello", ", ", "world"], "Hello, world"),
("Hello", "Hello"),
({"Hel": 1, "lo": [2]}, "Hello"),
(["12", "12.0"], "1212.0"),
([], ""),
("", ""),
({}, ""),
(MANY_TOKENS, "".join(MANY_TOKENS)),
],
)
def test_streaming_poll_joins_the_prediction_output_the_way_str_join_does(output: object, text: str):
assert _poll({"status": "succeeded", "output": output}) == [{"output": text, "status": "succeeded"}]
@pytest.mark.parametrize("status", [None, 1, ["succeeded"], {"state": "succeeded"}])
def test_streaming_poll_passes_an_unrecognised_status_through_and_keeps_polling(status: object):
chunks = _poll({"status": status, "output": ["a"]}, {"status": "succeeded", "output": ["a", "b"]})
assert chunks == [{"output": "a", "status": status}, {"output": "b", "status": "succeeded"}]
@pytest.mark.parametrize(
"output",
[
None,
5,
0,
1.5,
True,
["a", 1],
["a", None],
[["a"]],
[{"a": 1}],
[*MANY_TOKENS[:403], 403, *MANY_TOKENS[404:]],
],
)
def test_streaming_poll_reports_an_output_that_is_not_text_as_unprocessable(output: object):
with pytest.raises(ReplicateError) as exc_info:
_poll({"status": "succeeded", "output": output})
assert exc_info.value.status_code == 422
assert exc_info.value.message == f"Unable to parse response. Got={output}"
@pytest.mark.parametrize(
("body", "message"),
[
({"status": "failed", "error": "boom"}, "Error: boom"),
({"status": "failed", "error": None}, "Error: None"),
({"status": "failed"}, "Error: "),
({"status": "failed", "error": {"detail": "bad input"}}, "Error: {'detail': 'bad input'}"),
],
)
def test_streaming_poll_raises_the_provider_error_of_a_failed_prediction(body: object, message: str):
with pytest.raises(ReplicateError) as exc_info:
_poll(body)
assert exc_info.value.status_code == 400
assert exc_info.value.message == message
@pytest.mark.parametrize("body", [{}, {"output": ["a"]}])
def test_streaming_poll_raises_key_error_when_the_prediction_has_no_status(body: object):
with pytest.raises(KeyError) as exc_info:
_poll(body)
assert exc_info.value.args == ("status",)
@pytest.mark.parametrize(
"body", [[{"status": "succeeded"}], [], "Request timed out", ["secret-output", 429], 3, True, 1.5]
)
def test_streaming_poll_rejects_a_body_that_is_not_an_object_without_echoing_it(body: object):
with pytest.raises(ValidationError) as exc_info:
_poll(body)
assert [(error["type"], error["loc"]) for error in exc_info.value.errors()] == [("dict_type", ())]
assert "secret-output" not in str(exc_info.value)
assert "429" not in str(exc_info.value)
assert "Request timed out" not in str(exc_info.value)
def test_streaming_poll_keeps_the_chunks_yielded_before_a_malformed_body():
chunks = []
with respx.mock:
respx.get(POLL_URL).mock(
side_effect=[
httpx.Response(200, json={"status": "processing", "output": ["a"]}),
httpx.Response(200, json=["not", "an", "object"]),
]
)
stream = handle_prediction_response_streaming(
POLL_URL, "replicate-test-key", lambda _message: None, headers={}, http_client=HTTPHandler()
)
chunks.append(next(stream))
with pytest.raises(ValidationError):
next(stream)
assert chunks == [{"output": "a", "status": "processing"}]
def test_streaming_poll_retries_after_a_non_200_status():
with respx.mock:
respx.get(POLL_URL).mock(
side_effect=[
httpx.Response(503, text="busy"),
httpx.Response(200, json={"status": "succeeded", "output": ["ok"]}),
]
)
chunks = list(
handle_prediction_response_streaming(
POLL_URL, "replicate-test-key", lambda _message: None, headers={}, http_client=HTTPHandler()
)
)
assert chunks == [{"output": "ok", "status": "succeeded"}]
def _stream_completion(*poll_bodies: object) -> list[str | None]:
with respx.mock:
respx.post(CREATE_URL).mock(return_value=httpx.Response(201, json={"urls": {"get": POLL_URL}}))
respx.get(POLL_URL).mock(side_effect=[httpx.Response(200, json=body) for body in poll_bodies])
stream = litellm.completion(
model="replicate/meta/meta-llama-3-8b-instruct",
messages=[{"role": "user", "content": "hi"}],
stream=True,
api_key="replicate-test-key",
)
return [chunk.choices[0].delta.content for chunk in stream]
def test_streaming_completion_emits_each_polled_delta_as_content():
contents = _stream_completion(
{"status": "processing", "output": ["Hel"]}, {"status": "succeeded", "output": ["Hel", "lo"]}
)
assert contents == ["Hel", "lo", None]
def test_streaming_completion_maps_a_non_text_output_to_unprocessable_entity():
with pytest.raises(litellm.UnprocessableEntityError) as exc_info:
_stream_completion({"status": "succeeded", "output": ["a", 1]})
assert "Unable to parse response. Got=['a', 1]" in str(exc_info.value)
@pytest.mark.parametrize("body", [["x"], "Request timed out", ["Request was throttled", 429]])
def test_streaming_completion_maps_a_non_object_poll_body_to_an_api_error_whatever_text_it_carries(body: object):
with pytest.raises(MidStreamFallbackError) as exc_info:
_stream_completion(body)
assert type(exc_info.value.original_exception) is litellm.APIError
assert exc_info.value.original_exception.status_code == 500