litellm/tests/unit/test_router_streaming_fallback_metadata.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

187 lines
6 KiB
Python

import json
from unittest.mock import MagicMock
import pytest
import litellm
from litellm.proxy.proxy_server import _should_include_fallback_errors
from litellm.router import Router
from litellm.router_utils.add_retry_fallback_headers import get_hidden_params_dict
def test_apply_fallback_hidden_params_copies_from_fallback_response():
fallback_errors = [
{
"message": "litellm.RateLimitError: upstream limited request",
"type": "RateLimitError",
"param": None,
"code": "429",
}
]
chunk = litellm.ModelResponseStream(
id="test",
model="openai/internal-fallback",
choices=[],
)
chunk._hidden_params = {
"additional_headers": {"x-existing-chunk-header": "keep"},
"model_id": "chunk-model-id",
}
fallback_response = MagicMock()
fallback_response._hidden_params = {
"additional_headers": {
"x-litellm-attempted-fallbacks": 1,
"x-litellm-model-group": "fallback-model",
"x-litellm-fallback-errors": json.dumps(fallback_errors),
},
"api_base": "https://fallback.example",
}
Router._apply_fallback_hidden_params_to_item(
fallback_item=chunk,
prepared_fallback_hidden_params=Router._prepare_fallback_hidden_params(
fallback_response
),
)
assert chunk._hidden_params["api_base"] == "https://fallback.example"
assert chunk._hidden_params["model_id"] == "chunk-model-id"
assert chunk._hidden_params["additional_headers"] == {
"x-existing-chunk-header": "keep",
"x-litellm-attempted-fallbacks": 1,
"x-litellm-model-group": "fallback-model",
"x-litellm-fallback-errors": json.dumps(fallback_errors),
}
def _two_group_fallback_router() -> Router:
return litellm.Router(
model_list=[
{
"model_name": "primary-model",
"litellm_params": {"model": "openai/gpt-fake", "api_key": "sk-fake"},
},
{
"model_name": "fallback-model",
"litellm_params": {"model": "openai/gpt-fake-2", "api_key": "sk-fake"},
},
],
fallbacks=[{"primary-model": ["fallback-model"]}],
)
def _additional_headers(response: object) -> dict:
return get_hidden_params_dict(response).get("additional_headers", {})
@pytest.mark.asyncio
async def test_include_fallback_errors_propagates_through_router():
router = _two_group_fallback_router()
response = await router.acompletion(
model="primary-model",
messages=[{"role": "user", "content": "Hello"}],
mock_testing_fallbacks=True,
mock_response="fallback success",
include_fallback_errors=True,
)
headers = _additional_headers(response)
assert headers["x-litellm-attempted-fallbacks"] == 1
errors = json.loads(headers["x-litellm-fallback-errors"])
assert isinstance(errors, list) and len(errors) >= 1
assert set(errors[0].keys()) == {"message", "type", "param", "code"}
@pytest.mark.asyncio
async def test_router_omits_fallback_errors_without_opt_in():
router = _two_group_fallback_router()
response = await router.acompletion(
model="primary-model",
messages=[{"role": "user", "content": "Hello"}],
mock_testing_fallbacks=True,
mock_response="fallback success",
)
headers = _additional_headers(response)
assert headers["x-litellm-attempted-fallbacks"] == 1
assert "x-litellm-fallback-errors" not in headers
def test_prepare_fallback_hidden_params_no_additional_headers():
class FakeResponse:
_hidden_params = {"api_base": "http://example.com"}
hidden_params, headers = Router._prepare_fallback_hidden_params(FakeResponse())
assert hidden_params == {"api_base": "http://example.com"}
assert headers == {}
def test_apply_fallback_hidden_params_to_item_none_item():
Router._apply_fallback_hidden_params_to_item(
None, ({"api_base": "http://fallback.example"}, {"x-custom": "value"})
)
def test_apply_fallback_hidden_params_to_item_no_existing_additional_headers():
class FakeChunk:
_hidden_params = {"model_id": "test-id"}
chunk = FakeChunk()
Router._apply_fallback_hidden_params_to_item(
chunk,
(
{"api_base": "http://fallback.example"},
{"x-litellm-attempted-fallbacks": 1},
),
)
assert chunk._hidden_params["api_base"] == "http://fallback.example"
assert chunk._hidden_params["model_id"] == "test-id"
assert chunk._hidden_params["additional_headers"] == {
"x-litellm-attempted-fallbacks": 1
}
@pytest.mark.asyncio
async def test_set_response_headers_adds_model_group_to_streaming_wrapper():
class StreamingWrapper:
def __init__(self):
self._hidden_params = {"additional_headers": {"x-existing": "keep"}}
router = litellm.Router(model_list=[])
response = StreamingWrapper()
result = await router.set_response_headers(
response=response,
model_group="fallback-model",
)
assert result is response
assert response._hidden_params["additional_headers"] == {
"x-existing": "keep",
"x-litellm-model-group": "fallback-model",
}
def test_should_include_fallback_errors_gated_by_operator_setting():
request_data: dict = {"include_fallback_errors": True}
import litellm.proxy.proxy_server as ps
original = ps.general_settings.copy() if isinstance(ps.general_settings, dict) else {}
try:
ps.general_settings = {}
assert _should_include_fallback_errors(request_data) is False
ps.general_settings = {"expose_fallback_errors_to_caller": False}
assert _should_include_fallback_errors(request_data) is False
ps.general_settings = {"expose_fallback_errors_to_caller": True}
assert _should_include_fallback_errors(request_data) is True
ps.general_settings = {"expose_fallback_errors_to_caller": True}
assert _should_include_fallback_errors({}) is False
finally:
ps.general_settings = original