litellm/tests/unit/test_model_response_normalization.py
yuneng-jiang f6882246d4
test: move tests/test_litellm root and small trees into tests/unit (#43186)
* ci: run the unit_selection.sh shard files on every event instead of only fork pull requests

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* ci: rename fork-flag to unit-flag now that it applies on every event

* test: move tests/test_litellm root and small trees into tests/unit

Pure renames, no content changes. Follow-up commits in this PR fix
references, merge the three files that already existed in tests/unit,
keep live-provider tests in tests/test_litellm and wire CI.

* test: carry tests/test_litellm conftest isolation into tests/unit

Callback lists, routing fallbacks, cached HTTP clients, logger state, AWS,
proxy-URL and keychain env, and session-end client cleanup now reset for
unit tests too. The environment isolation owns its MonkeyPatch so a test's
own monkeypatch is undone before the model-cost teardown runs.

* test: merge, split and prune the moved root and small-tree tests

Merge batches/test_batch_utils.py and the chat_completions and messages
dispatch tests into the files that already existed in tests/unit. Keep
the live Gemini interactions tests, the async image-fetch format test and
the OpenAI embedding scorer test in tests/test_litellm since they need
real network or keys. Put test_router.py under tests/unit/test_router so
the existing package no longer shadows it. Delete eight tests the audit
found superseded by stronger ones kept in this move.

* ci: run the moved root and small-tree tests under their legacy flags

Add the misc and responses-caching-types flags to unit_selection.sh and
CircleCI, extend enterprise-routing and mcp-integration, and point the
legacy GHA shards, Makefile, redis-compat workflow, merge smoke manifest
and change classifier at the new paths.

* test: make the new tests/unit directories packages

tests/unit/test_package_layout.py requires every directory to carry an
__init__.py, and without one the moved and retained
test_litellm_responses_bridge.py modules collide on import.

* test: scope the unit socket block to tests/unit in shared sessions

The GHA shards collect the legacy test-path and the unit selection in one
pytest session. The unit conftest's loopback-only block leaked into legacy
modules that reach the network at import. The legacy conftest now lifts the
restriction at collect and setup time, and the unit conftest re-applies it
when collecting its own modules.

* test: give the shard-script tests their own GITHUB_OUTPUT

They only passed where the runner set it. The CircleCI unit job's env
allowlist drops it, so the script's redirect failed there.

* test: point the router and module-deletion checks at tests/unit

router_code_coverage and code_qa_check_tests only searched tests/test_litellm,
so the moved router tests no longer counted. The two silent-experiment tests
the audit deleted were the only direct callers of those methods; they are
replaced with tests that assert the forwarded shadow request and the
recursion guard.

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-25 11:30:43 -07:00

128 lines
4.1 KiB
Python

import warnings
import pytest
from litellm.types.utils import (
Choices,
Delta,
Message,
ModelResponse,
ModelResponseStream,
StreamingChoices,
)
def test_modelresponse_normalizes_openai_base_models() -> None:
# OpenAI SDK returns Pydantic BaseModel objects for message/choice.
# LiteLLM should normalize these into its own internal `Message` / `Choices` types.
from openai.types.chat.chat_completion import Choice as OpenAIChoice
from openai.types.chat.chat_completion_message import ChatCompletionMessage
message = ChatCompletionMessage(role="assistant", content="hi")
choice = OpenAIChoice(finish_reason="stop", index=0, message=message, logprobs=None)
with warnings.catch_warnings(record=True) as captured:
warnings.simplefilter("always")
response = ModelResponse(model="gpt-4o-mini", choices=[choice])
_ = response.model_dump()
assert isinstance(response.choices[0], Choices)
assert isinstance(response.choices[0].message, Message)
assert not any(
"Pydantic serializer warnings" in str(w.message)
for w in captured
if isinstance(w.message, Warning)
)
def test_modelresponse_serialization_avoids_pydantic_warnings() -> None:
pytest.importorskip("openai")
from openai.types.chat import ChatCompletion as OpenAIChatCompletion
openai_completion = OpenAIChatCompletion(
id="test-1",
created=1719868600,
model="gpt-4o-mini",
object="chat.completion",
choices=[
{
"index": 0,
"finish_reason": "stop",
"message": {"role": "assistant", "content": "hi"},
"logprobs": None,
}
],
usage={"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
)
with warnings.catch_warnings(record=True) as captured:
warnings.simplefilter("always")
response = ModelResponse(**openai_completion.model_dump())
_ = response.model_dump(exclude_none=True)
assert not any(
"PydanticSerializationUnexpectedValue" in str(w.message)
or "Pydantic serializer warnings" in str(w.message)
for w in captured
)
def test_modelresponse_model_dump_json_no_pydantic_warnings() -> None:
"""model_dump_json() and model_dump() should not trigger any Pydantic
serialization warnings now that choices is List[Choices] (no Union)."""
response = ModelResponse(
model="test-model",
choices=[
Choices(
finish_reason="stop",
index=0,
message=Message(content="hello", role="assistant"),
)
],
)
with warnings.catch_warnings(record=True) as captured:
warnings.simplefilter("always")
_ = response.model_dump_json()
_ = response.model_dump()
_ = response.model_dump(exclude_none=True)
pydantic_warnings = [
w
for w in captured
if "PydanticSerializationUnexpectedValue" in str(w.message)
or "Pydantic serializer warnings" in str(w.message)
]
assert (
pydantic_warnings == []
), f"Unexpected Pydantic serialization warnings: {pydantic_warnings}"
def test_streaming_modelresponsestream_no_pydantic_warnings() -> None:
"""Streaming responses use ModelResponseStream with List[StreamingChoices]
and should serialize without warnings."""
response = ModelResponseStream(
choices=[
StreamingChoices(
finish_reason="stop",
index=0,
delta=Delta(content="hello", role="assistant"),
)
],
)
with warnings.catch_warnings(record=True) as captured:
warnings.simplefilter("always")
_ = response.model_dump_json()
_ = response.model_dump()
pydantic_warnings = [
w
for w in captured
if "PydanticSerializationUnexpectedValue" in str(w.message)
or "Pydantic serializer warnings" in str(w.message)
]
assert (
pydantic_warnings == []
), f"Unexpected Pydantic serialization warnings: {pydantic_warnings}"