litellm/tests/test_litellm/proxy/test_init_litellm_callbacks.py
yuneng-jiang 3357ec8d34
test: run the 30 test files stranded in the second mirror (#37595)
* test: run the 30 test files stranded in the second mirror

tests/litellm sat beside tests/test_litellm, which is the mirror the repo
convention names, and no job collected it. The allowlist called the directory
unresolved and assumed it was a duplicate. It is not: 30 of its 34 files have no
counterpart in the real mirror, so they are tests nobody has run since they were
written, not copies of tests that run elsewhere.

Moving them in is byte-identical, and it is what makes them run. Every one is
now claimed by a shard's test-path rather than by an allowlist entry, and the
216 tests they hold pass. Directories that needed to become packages did, since
several files are named test_transformation.py and pytest cannot import two of
those from non-package directories in one session.

Never running is why three assertions had drifted away from the code:

  * nvidia.nemotron-super-3-120b max_output_tokens, 32000 -> 32768
  * sambanova/MiniMax-M2.7 max_input_tokens, 204800 -> 196608
  * the Vertex text-to-speech handler moved from data= to json=, so the test
    reads the decoded body off the json kwarg instead of parsing the data one

The first two follow model_prices_and_context_window.json, which the catalog
sync keeps current; the third follows the handler. In all three the test was the
stale side.

The lint workflow ran test_no_hardcoded_secrets.py by path and now points at the
new one.

Four files stay behind. Each shares a filename with a live test whose contents
are disjoint from it, so landing those means merging test bodies, which is a
content review rather than a move. The allowlist entry now names those four and
records how many tests each would bring, in place of calling the whole
directory unresolved.

* fix(ci): keep the secret scan out of the mirror's conftest

The secret-scan job runs pytest under uv run --no-project, so its environment
holds pytest and nothing else. That worked while the file sat in tests/litellm,
which has no conftest, and broke the moment it moved into tests/test_litellm,
whose conftest imports litellm on collection: ModuleNotFoundError: No module
named 'dotenv', before a single test ran.

The file is a repo-wide static scan that imports only base64, os, re and pytest,
so it belongs with the other repo-wide checks in tests/code_coverage_tests,
which has no conftest, rather than in the package mirror. Installing the full
dependency set into a 15-second job to satisfy a conftest it does not use would
be the wrong trade.

Verified with the job's exact command:
  uv run --no-project --with 'pytest==9.0.2' pytest \
    tests/code_coverage_tests/test_no_hardcoded_secrets.py -q
  1 passed in 0.47s
2026-08-20 10:59:43 -07:00

175 lines
6.1 KiB
Python

"""
Unit tests for ProxyLogging._init_litellm_callbacks.
Validates that string callbacks in litellm.callbacks are replaced in-place
with their initialized instances, preventing duplicate entries (string + instance)
that caused double-counting of metrics like litellm_proxy_total_requests_metric.
"""
from typing import List, Union
from unittest.mock import MagicMock, patch
import pytest
import litellm
from litellm.integrations.custom_logger import CustomLogger
class FakeCustomLogger(CustomLogger):
"""A minimal CustomLogger subclass for testing."""
pass
class TestInitLitellmCallbacks:
"""Tests for ProxyLogging._init_litellm_callbacks."""
def _make_proxy_logging(self):
"""Create a ProxyLogging instance with mocked dependencies."""
from litellm.proxy.utils import ProxyLogging
mock_cache = MagicMock()
proxy_logging = ProxyLogging(user_api_key_cache=mock_cache)
return proxy_logging
@patch(
"litellm.proxy.utils.ProxyLogging._add_proxy_hooks",
new_callable=lambda: lambda self, *a, **kw: None,
)
def test_should_replace_string_callback_with_instance(self, _mock_hooks):
"""
When litellm.callbacks contains a string callback (e.g. "lago"),
_init_litellm_callbacks should replace the string with the initialized
CustomLogger instance, not leave both the string and instance in the list.
"""
fake_logger = FakeCustomLogger()
# Start with a string callback in litellm.callbacks
litellm.callbacks = ["lago"] # type: ignore
proxy_logging = self._make_proxy_logging()
with patch(
"litellm.litellm_core_utils.litellm_logging._init_custom_logger_compatible_class",
return_value=fake_logger,
):
proxy_logging._init_litellm_callbacks(llm_router=None)
# The string "lago" should be replaced by the instance, not appended
string_entries = [c for c in litellm.callbacks if isinstance(c, str)]
instance_entries = [
c for c in litellm.callbacks if isinstance(c, FakeCustomLogger)
]
assert (
len(string_entries) == 0
), f"String callbacks should have been replaced, but found: {string_entries}"
assert (
len(instance_entries) == 1
), f"Expected exactly one FakeCustomLogger instance, found {len(instance_entries)}"
assert instance_entries[0] is fake_logger
# Clean up
litellm.callbacks = [] # type: ignore
@patch(
"litellm.proxy.utils.ProxyLogging._add_proxy_hooks",
new_callable=lambda: lambda self, *a, **kw: None,
)
def test_should_not_duplicate_existing_instance_callbacks(self, _mock_hooks):
"""
When litellm.callbacks already contains a CustomLogger instance (not a string),
_init_litellm_callbacks should not create a duplicate.
"""
existing_logger = FakeCustomLogger()
litellm.callbacks = [existing_logger] # type: ignore
proxy_logging = self._make_proxy_logging()
proxy_logging._init_litellm_callbacks(llm_router=None)
# Count how many FakeCustomLogger instances are in litellm.callbacks
instance_count = sum(
1 for c in litellm.callbacks if isinstance(c, FakeCustomLogger)
)
assert instance_count == 1, (
f"Expected exactly 1 FakeCustomLogger instance, found {instance_count}. "
f"litellm.callbacks = {litellm.callbacks}"
)
# Clean up
litellm.callbacks = [] # type: ignore
@patch(
"litellm.proxy.utils.ProxyLogging._add_proxy_hooks",
new_callable=lambda: lambda self, *a, **kw: None,
)
def test_should_handle_unrecognized_string_callback(self, _mock_hooks):
"""
When _init_custom_logger_compatible_class returns None for a string callback,
the string should remain in litellm.callbacks (not crash).
"""
litellm.callbacks = ["unknown_callback"] # type: ignore
proxy_logging = self._make_proxy_logging()
with patch(
"litellm.litellm_core_utils.litellm_logging._init_custom_logger_compatible_class",
return_value=None,
):
proxy_logging._init_litellm_callbacks(llm_router=None)
# The unknown string callback should still be there (not replaced, not crashed)
assert "unknown_callback" in litellm.callbacks
# Clean up
litellm.callbacks = [] # type: ignore
@patch(
"litellm.proxy.utils.ProxyLogging._add_proxy_hooks",
new_callable=lambda: lambda self, *a, **kw: None,
)
def test_should_replace_multiple_string_callbacks(self, _mock_hooks):
"""
When litellm.callbacks contains multiple string callbacks,
each should be replaced with its corresponding initialized instance.
"""
fake_logger_a = FakeCustomLogger()
fake_logger_b = FakeCustomLogger()
litellm.callbacks = ["callback_a", "callback_b"] # type: ignore
proxy_logging = self._make_proxy_logging()
call_count = 0
def mock_init_class(callback_name, **kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
return fake_logger_a
return fake_logger_b
with patch(
"litellm.litellm_core_utils.litellm_logging._init_custom_logger_compatible_class",
side_effect=mock_init_class,
):
proxy_logging._init_litellm_callbacks(llm_router=None)
string_entries = [c for c in litellm.callbacks if isinstance(c, str)]
instance_entries = [
c for c in litellm.callbacks if isinstance(c, FakeCustomLogger)
]
assert (
len(string_entries) == 0
), f"All string callbacks should have been replaced: {string_entries}"
assert (
len(instance_entries) == 2
), f"Expected 2 FakeCustomLogger instances, found {len(instance_entries)}"
assert instance_entries[0] is fake_logger_a
assert instance_entries[1] is fake_logger_b
# Clean up
litellm.callbacks = [] # type: ignore