diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index fddecb3d6eb..145e162a5fe 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -12708,6 +12708,7 @@ async def model_list( from litellm.proxy.utils import ( create_model_info_response, get_available_models_for_user, + resolve_model_provider_and_created_at, ) from litellm.types.proxy.model_listing import ModelInfoResponse @@ -12820,9 +12821,11 @@ async def model_list( TeamModelNameTranslator.listing_entries(all_models, llm_router, settings), user_api_key_dict ) for response_id, lookup_id in admin_entries: + provider, created = resolve_model_provider_and_created_at(llm_router, lookup_id) model_info = create_model_info_response( model_id=lookup_id, - provider="openai", + provider=provider, + created=created, include_metadata=include_metadata or False, fallback_type=fallback_type, llm_router=llm_router, @@ -12901,9 +12904,11 @@ async def model_list( ) labels: Final = label_entries(listed_entries, entries) for response_id, lookup_id in entries: + provider, created = resolve_model_provider_and_created_at(llm_router, lookup_id) model_info = create_model_info_response( model_id=lookup_id, - provider="openai", + provider=provider, + created=created, include_metadata=include_metadata or False, fallback_type=fallback_type, llm_router=llm_router, @@ -12980,6 +12985,7 @@ async def model_info( from litellm.proxy.utils import ( create_model_info_response, get_available_models_for_user, + provider_and_created_at_for_deployment, validate_model_access, ) @@ -13066,8 +13072,7 @@ async def model_info( detail=f"Model '{model_id}' not found in router configuration", ) - # Use the actual litellm model from the deployment to get provider info - _, provider, _, _ = litellm.get_llm_provider(model=deployment.litellm_params.model) + provider, created = provider_and_created_at_for_deployment(deployment) response: Final = create_model_info_response( model_id=resolved_model_id, @@ -13075,6 +13080,7 @@ async def model_info( include_metadata=False, fallback_type=None, llm_router=llm_router, + created=created, ) response_id: Final = model_id if aliased_model_id else internal_to_public.get(resolved_model_id, model_id) return {**response, "id": response_id} diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index 71ecae74058..dff5b868fac 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -325,6 +325,7 @@ if TYPE_CHECKING: from litellm.repositories.prisma_protocols import TableActions from litellm.types.mcp_server.mcp_server_manager import MCPServer from litellm.types.proxy.policy_engine.pipeline_types import GuardrailPipeline, PipelineStep + from litellm.types.router import Deployment Span = _Span | object else: @@ -10221,12 +10222,34 @@ def _group_token_limit(candidate_sets: tuple[tuple[ModelInfo, ...], ...], field: return max(limits) if limits else None +def provider_and_created_at_for_deployment(deployment: "Deployment") -> tuple[str, int | None]: + """ + The (owned_by, created) pair for a deployment, with ``created`` None when it has no real + timestamp (``created_at`` is only populated for DB models on Enterprise). Callers fall + back to DEFAULT_MODEL_CREATED_AT_TIME. + """ + _, provider, _, _ = litellm.get_llm_provider(model=deployment.litellm_params.model) + created_at: Final = deployment.model_info.created_at + return provider, int(created_at.timestamp()) if created_at is not None else None + + +def resolve_model_provider_and_created_at(llm_router: Optional["Router"], model_id: str) -> tuple[str, int | None]: + """Like ``provider_and_created_at_for_deployment`` for a listed model group, ("openai", None) when unbacked.""" + if llm_router is None: + return "openai", None + deployment: Final = llm_router.get_deployment_by_model_group_name(llm_router.routable_model_group(model_id)) + if deployment is None: + return "openai", None + return provider_and_created_at_for_deployment(deployment) + + def create_model_info_response( model_id: str, provider: str, include_metadata: bool = False, fallback_type: str | None = None, llm_router: Optional["Router"] = None, + created: int | None = None, get_model_info: Callable[[str], ModelInfo] = litellm.get_model_info, ) -> ModelInfoResponse: """ @@ -10241,7 +10264,7 @@ def create_model_info_response( base: Final[ModelInfoResponse] = { "id": model_id, "object": "model", - "created": DEFAULT_MODEL_CREATED_AT_TIME, + "created": created if created is not None else DEFAULT_MODEL_CREATED_AT_TIME, "owned_by": provider, } diff --git a/tests/unit/proxy/proxy_server/test_routes_models.py b/tests/unit/proxy/proxy_server/test_routes_models.py index ff74cd3fa1d..2efdc01c36b 100644 --- a/tests/unit/proxy/proxy_server/test_routes_models.py +++ b/tests/unit/proxy/proxy_server/test_routes_models.py @@ -816,3 +816,30 @@ async def test_dashboard_lists_not_asking_for_allowlist_names_offer_no_typesafe_ ) assert {model["id"] for model in response["data"]}.isdisjoint({"typesafe/*", *_priced_jev_models()}) + + +@pytest.mark.parametrize("path", ["/v1/models", "/models"]) +def test_get_models_reports_real_provider_per_model(client, auth_as, monkeypatch, path): + router: Final = Router( + model_list=[ + {"model_name": "gpt-4", "litellm_params": {"model": "gpt-4", "api_key": "sk-test"}}, + { + "model_name": "claude-sonnet", + "litellm_params": {"model": "anthropic/claude-3-5-sonnet-latest", "api_key": "sk-test"}, + }, + ] + ) + monkeypatch.setattr(proxy_server, "llm_router", router) + monkeypatch.setattr(proxy_server, "prisma_client", MagicMock()) + + async def _fake_get_available_models_for_user(**kwargs): + return ["gpt-4", "claude-sonnet"] + + monkeypatch.setattr(proxy_utils, "get_available_models_for_user", _fake_get_available_models_for_user) + + with auth_as(): + response = client.get(path) + + assert response.status_code == 200 + owned_by = {m["id"]: m["owned_by"] for m in response.json()["data"]} + assert owned_by == {"gpt-4": "openai", "claude-sonnet": "anthropic"} diff --git a/tests/unit/proxy/proxy_server/test_team_model_name_translation.py b/tests/unit/proxy/proxy_server/test_team_model_name_translation.py index b647fbdd02a..1342108ab10 100644 --- a/tests/unit/proxy/proxy_server/test_team_model_name_translation.py +++ b/tests/unit/proxy/proxy_server/test_team_model_name_translation.py @@ -886,6 +886,7 @@ async def test_v1_models_translates_team_model_for_access_group_key(monkeypatch) router.get_model_access_groups.return_value = {"grp-a": ["model_name_teamX_uuid9"]} router.get_fully_blocked_model_names.return_value = set() router.get_model_listing_info.return_value = None + router.get_deployment_by_model_group_name.return_value = None router.model_list = [team_dep] router.get_model_list.return_value = [team_dep] @@ -928,6 +929,7 @@ async def test_v1_models_keeps_internal_names_when_public_name_flag_disabled( router.get_model_access_groups.return_value = {"grp-a": ["model_name_teamX_uuid9"]} router.get_fully_blocked_model_names.return_value = set() router.get_model_listing_info.return_value = None + router.get_deployment_by_model_group_name.return_value = None router.model_list = [team_dep] router.get_model_list.return_value = [team_dep] @@ -963,9 +965,11 @@ async def test_v1_models_translates_team_model_with_metadata(monkeypatch): router.get_model_access_groups.return_value = {"grp-a": ["model_name_teamX_uuid9"]} router.get_fully_blocked_model_names.return_value = set() router.get_model_listing_info.return_value = None + router.get_deployment_by_model_group_name.return_value = None router.model_list = [team_dep] router.get_model_list.return_value = [team_dep] router.get_model_group_info.return_value = None + router.get_deployment_by_model_group_name.return_value = None monkeypatch.setattr(ps, "llm_router", router) monkeypatch.setattr(ps, "user_model", None) @@ -1009,11 +1013,13 @@ async def test_v1_models_metadata_fallbacks_use_internal_routing_key(monkeypatch router.get_model_access_groups.return_value = {"grp-a": ["model_name_teamX_uuid9"]} router.get_fully_blocked_model_names.return_value = set() router.get_model_listing_info.return_value = None + router.get_deployment_by_model_group_name.return_value = None router.model_list = [team_dep] router.get_model_list.return_value = [team_dep] # Fallbacks are keyed on the internal routing name, as the router stores them. router.fallbacks = [{"model_name_teamX_uuid9": ["gpt-4o-backup"]}] router.get_model_group_info.return_value = None + router.get_deployment_by_model_group_name.return_value = None monkeypatch.setattr(ps, "llm_router", router) monkeypatch.setattr(ps, "user_model", None) @@ -1066,6 +1072,7 @@ async def test_v1_models_metadata_does_not_leak_other_team_fallbacks(monkeypatch router.get_model_access_groups.return_value = {"grp-a": ["model_name_teamX_uuid9"]} router.get_fully_blocked_model_names.return_value = set() router.get_model_listing_info.return_value = None + router.get_deployment_by_model_group_name.return_value = None router.model_list = [team_x, team_y] router.get_model_list.return_value = [team_x, team_y] router.fallbacks = [ @@ -1073,6 +1080,7 @@ async def test_v1_models_metadata_does_not_leak_other_team_fallbacks(monkeypatch {"model_name_teamY_uuidZ": ["teamY-backup"]}, ] router.get_model_group_info.return_value = None + router.get_deployment_by_model_group_name.return_value = None monkeypatch.setattr(ps, "llm_router", router) monkeypatch.setattr(ps, "user_model", None) @@ -1122,6 +1130,7 @@ async def test_v1_models_team_alias_inherits_token_limits_and_chat_mode(monkeypa router.model_list = [team_dep] router.get_model_list.return_value = [team_dep] router.get_model_group_info.return_value = None + router.get_deployment_by_model_group_name.return_value = None monkeypatch.setattr(ps, "llm_router", router) monkeypatch.setattr(ps, "user_model", None) @@ -1170,6 +1179,7 @@ async def test_v1_models_team_image_alias_inherits_image_generation_mode(monkeyp router.model_list = [team_dep] router.get_model_list.return_value = [team_dep] router.get_model_group_info.return_value = None + router.get_deployment_by_model_group_name.return_value = None monkeypatch.setattr(ps, "llm_router", router) monkeypatch.setattr(ps, "user_model", None) @@ -1416,6 +1426,7 @@ def _public_named_router(*team_rows: dict) -> MagicMock: router = route_entry_points_to_stubs(MagicMock()) router.get_model_list.return_value = list(team_rows) router.get_model_listing_info.return_value = None + router.get_deployment_by_model_group_name.return_value = None router.routable_model_group.side_effect = lambda model_name: model_name return router diff --git a/tests/unit/proxy/test_proxy_utils_model_creation_and_error_logging.py b/tests/unit/proxy/test_proxy_utils_model_creation_and_error_logging.py index 3b000ba741f..307a5a34f09 100644 --- a/tests/unit/proxy/test_proxy_utils_model_creation_and_error_logging.py +++ b/tests/unit/proxy/test_proxy_utils_model_creation_and_error_logging.py @@ -1579,7 +1579,10 @@ class TestPostCallFailureHookEstimatesDispatchedInputTokens: from typing import cast import litellm -from litellm.proxy.utils import create_model_info_response +from litellm.proxy.utils import ( + create_model_info_response, + resolve_model_provider_and_created_at, +) from litellm.types.router import DeploymentModelListingInfo from litellm.types.utils import ModelInfo, ModelResponse, Usage @@ -1880,6 +1883,60 @@ def test_create_model_info_response_no_router_keeps_base_fields(): } +def test_create_model_info_response_uses_created_when_given(): + response = create_model_info_response(model_id="some-model", provider="openai", llm_router=None, created=12345) + + assert response["created"] == 12345 + + +def test_create_model_info_response_defaults_created_when_none(): + from litellm.constants import DEFAULT_MODEL_CREATED_AT_TIME + + response = create_model_info_response(model_id="some-model", provider="openai", llm_router=None, created=None) + + assert response["created"] == DEFAULT_MODEL_CREATED_AT_TIME + + +def _router_with_models(*models: tuple[str, str, real_datetime.datetime | None]) -> "litellm.Router": + return litellm.Router( + model_list=[ + { + "model_name": name, + "litellm_params": {"model": model, "api_key": "sk-test"}, + "model_info": {"created_at": created_at} if created_at is not None else {}, + } + for name, model, created_at in models + ] + ) + + +def test_resolve_model_provider_and_created_at_reads_deployment(): + created_at: Final = real_datetime.datetime(2024, 1, 1, tzinfo=real_datetime.timezone.utc) + router: Final = _router_with_models(("claude-sonnet", "anthropic/claude-3-5-sonnet-latest", created_at)) + + provider, created = resolve_model_provider_and_created_at(router, "claude-sonnet") + + assert provider == "anthropic" + assert created == int(created_at.timestamp()) + + +def test_resolve_model_provider_and_created_at_without_created_at_returns_none(): + router: Final = _router_with_models(("gpt-4", "gpt-4", None)) + + assert resolve_model_provider_and_created_at(router, "gpt-4") == ("openai", None) + + +@pytest.mark.parametrize("model_id", ["not-a-configured-group", "openai/*"]) +def test_resolve_model_provider_and_created_at_unbacked_model_defaults_openai(model_id): + router: Final = _router_with_models(("gpt-4", "gpt-4", None)) + + assert resolve_model_provider_and_created_at(router, model_id) == ("openai", None) + + +def test_resolve_model_provider_and_created_at_no_router_defaults_openai(): + assert resolve_model_provider_and_created_at(None, "some-model") == ("openai", None) + + def test_create_model_info_response_reads_real_cost_map(): response = create_model_info_response(model_id="gpt-4o", provider="openai", llm_router=None)