Files
litellm/tests/test_litellm/proxy/utils/helpers/test_model_access.py
T
yuneng-jiang 1aed5e1bbd test(proxy/utils): pin bottom-of-file helper behavior (#29509)
* test(proxy/utils): pin bottom-of-file helper behavior

Pin current behavior of the bottom-of-file pure-function helpers in
litellm/proxy/utils.py (projection, team config, time helpers,
guardrail merge, error helpers, URL/path helpers, premium gate, model
access, and misc DB/API-key helpers).

Adds tests/test_litellm/proxy/utils/helpers/ with one happy + one error
test per pinned symbol; folds the prior single-test
tests/test_litellm/proxy/test_utils.py into test_url_helpers.py and
deletes the old file. _pin_check.py and _coverage_check.py serve as
local stopping gates.

Adds tests/test_litellm/proxy/utils to the existing test-path block in
.github/workflows/test-unit-proxy-endpoints.yml.

Plan:     https://www.notion.so/37343b8acdab81f68f39f66915f62bcf
Pin list: https://www.notion.so/37343b8acdab8150acdbf40e5756869f

* test(proxy/utils): apply greptile fixes to behavior-pinning gates

Address findings from the sibling PR1/PR2 greptile reviews that also
apply to this PR:

- Commit pin_list.txt alongside the gate script (was previously a
  gitignored .pin_list.txt fetched from Notion). The gate is now
  reproducible without out-of-band setup.
- Resolve the coverage region by locating the first pinned symbol's
  def line in litellm/proxy/utils.py at runtime, instead of hardcoded
  line numbers that drift when lines above shift.
- Word-boundary the pin reference check so pins like update_spend do
  not falsely match update_spend_logs_job.
- Drop the dead _harness_smoke_test.py exclusion; the test_*.py glob
  already filters underscore-prefixed files.

* test(proxy/utils): drop local-only stopping-signal scripts

Remove _pin_check.py, _coverage_check.py, and pin_list.txt. These were
dev-time tooling for knowing when test authoring was done; they are
not wired into CI and the test files themselves are the merge artifact.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-02 17:45:19 -07:00

407 lines
12 KiB
Python

from unittest.mock import MagicMock
import pytest
from fastapi import HTTPException
import litellm
from litellm import ModelResponse
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.utils import (
create_model_info_response,
get_available_models_for_user,
is_known_model,
is_known_vector_store_index,
model_dump_with_preserved_fields,
validate_model_access,
)
def normalize(value):
return value
def _router_with_models(model_names):
router = MagicMock()
router.get_model_names.return_value = model_names
router.get_model_access_groups.return_value = {}
return router
def test_is_known_model_happy_path_returns_true_when_in_router():
router = _router_with_models(["gpt-4o", "claude-haiku"])
summary = {
"result": is_known_model("gpt-4o", router),
"model": "gpt-4o",
"router_models": ["gpt-4o", "claude-haiku"],
}
assert summary == {
"result": True,
"model": "gpt-4o",
"router_models": ["gpt-4o", "claude-haiku"],
}
def test_is_known_model_returns_false_when_not_in_router():
router = _router_with_models(["gpt-4o"])
summary = {
"result": is_known_model("claude-haiku", router),
"model": "claude-haiku",
"router_models": ["gpt-4o"],
}
assert summary == {
"result": False,
"model": "claude-haiku",
"router_models": ["gpt-4o"],
}
def test_is_known_model_error_path_none_model():
router = _router_with_models(["gpt-4o"])
assert is_known_model(None, router) is False
def test_is_known_model_error_path_none_router():
assert is_known_model("gpt-4o", None) is False
def test_is_known_vector_store_index_happy_path(monkeypatch):
registry = MagicMock()
registry.get_vector_store_indexes.return_value = ["index-a", "index-b"]
monkeypatch.setattr(litellm, "vector_store_index_registry", registry)
summary = {
"result": is_known_vector_store_index("index-a"),
"indexes": ["index-a", "index-b"],
"input": "index-a",
}
assert summary == {
"result": True,
"indexes": ["index-a", "index-b"],
"input": "index-a",
}
def test_is_known_vector_store_index_returns_false_when_missing(monkeypatch):
registry = MagicMock()
registry.get_vector_store_indexes.return_value = ["index-a"]
monkeypatch.setattr(litellm, "vector_store_index_registry", registry)
summary = {
"result": is_known_vector_store_index("missing"),
"indexes": ["index-a"],
"input": "missing",
}
assert summary == {
"result": False,
"indexes": ["index-a"],
"input": "missing",
}
def test_is_known_vector_store_index_error_path_no_registry(monkeypatch):
monkeypatch.setattr(litellm, "vector_store_index_registry", None)
assert is_known_vector_store_index("anything") is False
def test_create_model_info_response_happy_path_no_metadata():
result = create_model_info_response(model_id="gpt-4o", provider="openai")
assert result == {
"id": "gpt-4o",
"object": "model",
"created": result["created"],
"owned_by": "openai",
}
snapshot = {
"id": result["id"],
"object": result["object"],
"owned_by": result["owned_by"],
"created_is_int": isinstance(result["created"], int),
"metadata_absent": "metadata" not in result,
}
assert snapshot == {
"id": "gpt-4o",
"object": "model",
"owned_by": "openai",
"created_is_int": True,
"metadata_absent": True,
}
def test_create_model_info_response_with_metadata_default_general(monkeypatch):
monkeypatch.setattr(
"litellm.proxy.auth.model_checks.get_all_fallbacks",
lambda **_kwargs: [{"model": "fallback-1"}],
)
result = create_model_info_response(
model_id="gpt-4o",
provider="openai",
include_metadata=True,
)
snapshot = {
"id": result["id"],
"owned_by": result["owned_by"],
"object": result["object"],
"fallbacks": result["metadata"]["fallbacks"],
}
assert snapshot == {
"id": "gpt-4o",
"owned_by": "openai",
"object": "model",
"fallbacks": [{"model": "fallback-1"}],
}
def test_create_model_info_response_with_explicit_fallback_type(monkeypatch):
captured = {}
def _capture(model, llm_router, fallback_type):
captured["fallback_type"] = fallback_type
return ["x"]
monkeypatch.setattr("litellm.proxy.auth.model_checks.get_all_fallbacks", _capture)
result = create_model_info_response(
model_id="gpt-4o",
provider="openai",
include_metadata=True,
fallback_type="context_window",
)
snapshot = {
"id": result["id"],
"fallbacks": result["metadata"]["fallbacks"],
"captured_fallback_type": captured["fallback_type"],
"owned_by": result["owned_by"],
}
assert snapshot == {
"id": "gpt-4o",
"fallbacks": ["x"],
"captured_fallback_type": "context_window",
"owned_by": "openai",
}
def test_create_model_info_response_invalid_fallback_type_raises():
with pytest.raises(HTTPException) as exc_info:
create_model_info_response(
model_id="gpt-4o",
provider="openai",
include_metadata=True,
fallback_type="bogus",
)
assert exc_info.value.status_code == 400
assert "Invalid fallback_type" in str(exc_info.value.detail)
def test_validate_model_access_happy_path_single_model_in_list():
summary = {
"result": validate_model_access("gpt-4o", ["gpt-4o", "claude-haiku"]),
"model": "gpt-4o",
"available": ["gpt-4o", "claude-haiku"],
}
assert summary == {
"result": None,
"model": "gpt-4o",
"available": ["gpt-4o", "claude-haiku"],
}
def test_validate_model_access_happy_path_batch_all_accessible():
summary = {
"result": validate_model_access(
"gpt-4o,claude-haiku", ["gpt-4o", "claude-haiku", "gemini"]
),
"input": "gpt-4o,claude-haiku",
"available": ["gpt-4o", "claude-haiku", "gemini"],
}
assert summary == {
"result": None,
"input": "gpt-4o,claude-haiku",
"available": ["gpt-4o", "claude-haiku", "gemini"],
}
def test_validate_model_access_single_model_not_accessible_raises():
with pytest.raises(HTTPException) as exc_info:
validate_model_access("missing-model", ["gpt-4o"])
assert exc_info.value.status_code == 404
assert "missing-model" in str(exc_info.value.detail)
def test_validate_model_access_batch_partial_inaccessible_raises():
with pytest.raises(HTTPException) as exc_info:
validate_model_access("gpt-4o,unknown-x", ["gpt-4o"])
assert exc_info.value.status_code == 404
assert "unknown-x" in str(exc_info.value.detail)
assert "gpt-4o" not in str(exc_info.value.detail).split("not accessible:")[1]
def _make_model_response():
return ModelResponse(
id="resp-123",
choices=[
{
"message": {
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {"name": "do_thing", "arguments": "{}"},
}
],
},
"index": 0,
"finish_reason": "tool_calls",
}
],
model="gpt-4o",
)
def test_model_dump_with_preserved_fields_restores_none_content():
resp = _make_model_response()
result = model_dump_with_preserved_fields(resp)
message = result["choices"][0]["message"]
snapshot = {
"content_is_none": message["content"] is None,
"role": message["role"],
"has_tool_calls": "tool_calls" in message,
"model": result["model"],
}
assert snapshot == {
"content_is_none": True,
"role": "assistant",
"has_tool_calls": True,
"model": "gpt-4o",
}
def test_model_dump_with_preserved_fields_no_choices_returns_plain_dump():
class _Bare:
def model_dump(self, **_kwargs):
return {"id": "x", "object": "y", "extra": "z"}
bare = _Bare()
result = model_dump_with_preserved_fields(bare)
assert result == {"id": "x", "object": "y", "extra": "z"}
def test_model_dump_with_preserved_fields_error_path_invalid_obj_raises():
with pytest.raises(AttributeError):
model_dump_with_preserved_fields(None)
@pytest.mark.asyncio
async def test_get_available_models_for_user_happy_path_returns_complete_list(
monkeypatch,
):
monkeypatch.setattr(
"litellm.proxy.auth.model_checks.get_key_models",
lambda **_k: ["gpt-4o"],
)
monkeypatch.setattr(
"litellm.proxy.auth.model_checks.get_team_models",
lambda **_k: ["claude-haiku"],
)
monkeypatch.setattr(
"litellm.proxy.auth.model_checks.get_complete_model_list",
lambda **_k: ["gpt-4o", "claude-haiku", "gemini"],
)
router = _router_with_models(["gpt-4o", "claude-haiku", "gemini"])
user_api_key_dict = UserAPIKeyAuth(
api_key="sk-test-key",
user_id="user-1",
team_id=None,
team_models=[],
)
result = await get_available_models_for_user(
user_api_key_dict=user_api_key_dict,
llm_router=router,
general_settings={},
user_model=None,
)
summary = {
"result_sorted": sorted(result),
"count": len(result),
"user_id": user_api_key_dict.user_id,
"router_set": True,
}
assert summary == {
"result_sorted": ["claude-haiku", "gemini", "gpt-4o"],
"count": 3,
"user_id": "user-1",
"router_set": True,
}
@pytest.mark.asyncio
async def test_get_available_models_for_user_with_none_router(monkeypatch):
monkeypatch.setattr(
"litellm.proxy.auth.model_checks.get_key_models",
lambda **_k: [],
)
monkeypatch.setattr(
"litellm.proxy.auth.model_checks.get_team_models",
lambda **_k: [],
)
monkeypatch.setattr(
"litellm.proxy.auth.model_checks.get_complete_model_list",
lambda **_k: ["user-model"],
)
user_api_key_dict = UserAPIKeyAuth(
api_key="sk-test-key",
user_id="user-1",
team_id=None,
team_models=[],
)
result = await get_available_models_for_user(
user_api_key_dict=user_api_key_dict,
llm_router=None,
general_settings={},
user_model="user-model",
)
summary = {
"result": result,
"router_is_none": True,
"user_model": "user-model",
"count": len(result),
}
assert summary == {
"result": ["user-model"],
"router_is_none": True,
"user_model": "user-model",
"count": 1,
}
@pytest.mark.asyncio
async def test_get_available_models_for_user_error_path_complete_list_raises(
monkeypatch,
):
monkeypatch.setattr(
"litellm.proxy.auth.model_checks.get_key_models",
lambda **_k: [],
)
monkeypatch.setattr(
"litellm.proxy.auth.model_checks.get_team_models",
lambda **_k: [],
)
def _boom(**_kwargs):
raise RuntimeError("downstream failure")
monkeypatch.setattr(
"litellm.proxy.auth.model_checks.get_complete_model_list", _boom
)
user_api_key_dict = UserAPIKeyAuth(
api_key="sk-test-key",
user_id="user-1",
team_id=None,
team_models=[],
)
with pytest.raises(RuntimeError):
await get_available_models_for_user(
user_api_key_dict=user_api_key_dict,
llm_router=None,
general_settings={},
user_model=None,
)