diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 849e54c65aa..c6ecb0be8b9 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -1216,9 +1216,9 @@ class GenerateKeyRequest(KeyRequestBase): organization_id: str | None = None project_id: str | None = None - @field_validator("team_id", mode="before") + @field_validator("team_id", "organization_id", mode="before") @classmethod - def treat_cleared_team_id_as_unset(cls, v: object) -> object: + def treat_cleared_id_as_unset(cls, v: object) -> object: if v == "": return None return v @@ -1278,6 +1278,13 @@ class UpdateKeyRequest(KeyRequestBase): rotation_interval: str | None = None organization_id: str | None = None + @field_validator("organization_id", mode="before") + @classmethod + def treat_cleared_organization_id_as_unset(cls, v: object) -> object: + if v == "": + return None + return v + @model_validator(mode="after") def validate_temp_budget(self) -> "UpdateKeyRequest": if self.temp_budget_increase is not None or self.temp_budget_expiry is not None: diff --git a/litellm/proxy/management_endpoints/model_management_endpoints.py b/litellm/proxy/management_endpoints/model_management_endpoints.py index ca66640bf46..613e726f89d 100644 --- a/litellm/proxy/management_endpoints/model_management_endpoints.py +++ b/litellm/proxy/management_endpoints/model_management_endpoints.py @@ -68,6 +68,10 @@ from litellm.proxy.management_endpoints.team_endpoints import ( from litellm.proxy.management_endpoints.team_endpoints import ( update_team as _legacy_update_team, ) +from litellm.proxy.management_helpers.access_group_model_sync import ( + sync_access_groups_for_deleted_model, + sync_access_groups_for_renamed_model, +) from litellm.proxy.management_helpers.audit_logs import create_object_audit_log from litellm.proxy.spend_tracking.ptu_feature_flag import ( PTU_COST_ATTRIBUTION_ENV_VAR, @@ -715,6 +719,7 @@ async def patch_model( existing_params=db_model.litellm_params, ) + requested_model_name: Final = patch_data.model_name # Handle team model updates with proper alias management update_data: Final = await _update_team_model_in_db( db_model=db_model, @@ -741,6 +746,20 @@ async def patch_model( param=None, ) + stored_model_name: Final = update_data.get("model_name") + if ( + stored_model_name is not None + and stored_model_name == requested_model_name + and stored_model_name != db_model.model_name + ): + await sync_access_groups_for_renamed_model( + prisma_client=prisma_client, + model_id=model_id, + old_name=db_model.model_name, + new_name=stored_model_name, + llm_router=llm_router, + ) + # Clear cache and reload models (uses config setting or defaults to preserving config models for DB updates) live_before_reload: Final = live_model_ids_snapshot() reload_outcome: Final = await clear_cache() @@ -1673,6 +1692,12 @@ async def delete_model( proxy_logging_obj=proxy_logging_obj, llm_router=llm_router, ) + await sync_access_groups_for_deleted_model( + prisma_client=prisma_client, + model_id=model_info.id, + model_name=model_params.model_name, + llm_router=llm_router, + ) ## CREATE AUDIT LOG ## asyncio.create_task( @@ -2027,25 +2052,36 @@ async def update_model( model_params.litellm_params[k] = encrypted_value ### MERGE WITH EXISTING DATA ### - merged_dictionary: Final = {} _mp: Final[dict[str, object]] = model_params.litellm_params.dict() + merged_dictionary: Final = { + key: _existing_litellm_params_dict[key] if value is None else value + for key, value in _mp.items() + if value is not None or _existing_litellm_params_dict.get(key) is not None + } - for key, value in _mp.items(): - if value is not None: - merged_dictionary[key] = value - elif key in _existing_litellm_params_dict and _existing_litellm_params_dict[key] is not None: - merged_dictionary[key] = _existing_litellm_params_dict[key] - else: - pass - + renamed_to: Final = ( + model_params.model_name + if model_params.model_name not in (None, deployment.model_name) + and deployment.model_info.team_id is None + else None + ) _data: Final[dict[str, str]] = { "litellm_params": json.dumps(merged_dictionary), "updated_by": user_api_key_dict.user_id or LITELLM_PROXY_ADMIN_NAME, + **({} if renamed_to is None else {"model_name": renamed_to}), } model_response: Final = await _proxy_model_table(prisma_client).update( where={"model_id": _model_id}, data=_data, ) + if renamed_to is not None: + await sync_access_groups_for_renamed_model( + prisma_client=prisma_client, + model_id=_model_id, + old_name=deployment.model_name, + new_name=renamed_to, + llm_router=llm_router, + ) # Clear cache and reload models (uses config setting or defaults to preserving config models for DB updates) live_before_reload: Final = live_model_ids_snapshot() diff --git a/litellm/proxy/management_helpers/access_group_model_sync.py b/litellm/proxy/management_helpers/access_group_model_sync.py new file mode 100644 index 00000000000..b9d81f2981f --- /dev/null +++ b/litellm/proxy/management_helpers/access_group_model_sync.py @@ -0,0 +1,119 @@ +""" +Keep `litellm_accessgrouptable.access_model_names` pointing at deployment names that still exist. + +Unified access groups store model names, not ids, so a deployment rename or delete that leaves +the arrays alone strands every group on a name nothing serves any more. +""" + +from collections.abc import Sequence +from typing import Final, Protocol + +from pydantic import BaseModel + +from litellm.proxy.db.routing_prisma_wrapper import WriterPinnedClient +from litellm.proxy.management_helpers.access_group_team_sync import invalidate_access_group_caches +from litellm.repositories.table_repositories import AccessGroupRepository +from litellm.router import Router + + +class _TouchedGroupRow(BaseModel): + access_group_id: str + + +class _DeploymentCountRow(BaseModel): + deployment_count: int + + +class _RawExecutor(Protocol): + async def query_raw(self, query: str, *args: str) -> Sequence[object]: ... + + +_BACKING_DEPLOYMENTS_SQL: Final = ( + 'SELECT COUNT(*)::int AS deployment_count FROM "LiteLLM_ProxyModelTable" WHERE "model_name" = $1' +) + +_REPLACE_MODEL_NAME_SQL: Final = ( + 'UPDATE "LiteLLM_AccessGroupTable" ' + 'SET "access_model_names" = array_replace(array_remove("access_model_names", $2), $1, $2) ' + 'WHERE $1 = ANY("access_model_names") ' + 'RETURNING "access_group_id"' +) + +_APPEND_MODEL_NAME_SQL: Final = ( + 'UPDATE "LiteLLM_AccessGroupTable" ' + 'SET "access_model_names" = array_append("access_model_names", $2) ' + 'WHERE $1 = ANY("access_model_names") AND NOT ($2 = ANY("access_model_names")) ' + 'RETURNING "access_group_id"' +) + +_REMOVE_MODEL_NAME_SQL: Final = ( + 'UPDATE "LiteLLM_AccessGroupTable" ' + 'SET "access_model_names" = array_remove("access_model_names", $1) ' + 'WHERE $1 = ANY("access_model_names") ' + 'RETURNING "access_group_id"' +) + + +def _raw_executor(prisma_client: object) -> _RawExecutor: + db: Final = AccessGroupRepository(prisma_client).prisma_client.db # pyright: ignore[reportAny] # untyped Prisma client + return WriterPinnedClient(db).db # pyright: ignore[reportAny, reportReturnType] # untyped Prisma client behind the pin + + +def _config_sourced_sibling(llm_router: Router, deployment_id: str, model_id: str) -> bool: + if deployment_id == model_id: + return False + deployment: Final = llm_router.get_deployment(model_id=deployment_id) + return deployment is not None and not deployment.model_info.db_model + + +def _served_by_a_config_deployment(llm_router: Router | None, model_name: str, model_id: str) -> bool: + if llm_router is None: + return False + return any( + _config_sourced_sibling(llm_router, deployment_id, model_id) + for deployment_id in llm_router.get_model_ids(model_name=model_name) + ) + + +async def _still_backed(executor: _RawExecutor, llm_router: Router | None, model_name: str, model_id: str) -> bool: + if _served_by_a_config_deployment(llm_router, model_name, model_id): + return True + count_rows: Final = await executor.query_raw(_BACKING_DEPLOYMENTS_SQL, model_name) + return any(_DeploymentCountRow.model_validate(row).deployment_count > 0 for row in count_rows) + + +async def _rewrite_groups(executor: _RawExecutor, sql: str, *names: str) -> None: + touched_rows: Final = await executor.query_raw(sql, *names) + await invalidate_access_group_caches( + tuple(_TouchedGroupRow.model_validate(row).access_group_id for row in touched_rows) + ) + + +async def sync_access_groups_for_renamed_model( + prisma_client: object, + *, + model_id: str, + old_name: str, + new_name: str, + llm_router: Router | None, +) -> None: + if old_name == new_name: + return + executor: Final = _raw_executor(prisma_client) + old_name_still_backed: Final = await _still_backed(executor, llm_router, old_name, model_id) + await _rewrite_groups( + executor, _APPEND_MODEL_NAME_SQL if old_name_still_backed else _REPLACE_MODEL_NAME_SQL, old_name, new_name + ) + + +async def sync_access_groups_for_deleted_model( + prisma_client: object, + *, + model_id: str, + model_name: str, + llm_router: Router | None, +) -> None: + executor: Final = _raw_executor(prisma_client) + if await _still_backed(executor, llm_router, model_name, model_id): + return + await _rewrite_groups(executor, _REMOVE_MODEL_NAME_SQL, model_name) diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py index bb7dfafb297..dcc798b81cb 100644 --- a/litellm/proxy/spend_tracking/spend_management_endpoints.py +++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py @@ -12,6 +12,7 @@ from typing import ( Literal, NamedTuple, Protocol, + TypeAlias, TypedDict, TypeVar, cast, # noqa: TID251 # prisma group_by returns untyped aggregate mappings @@ -19,6 +20,7 @@ from typing import ( import fastapi from fastapi import APIRouter, Depends, HTTPException, Request, Response, status +from pydantic import TypeAdapter from typing_extensions import ReadOnly import litellm @@ -158,6 +160,26 @@ class _SessionSpendRow(TypedDict): session_cache_hit_count: ReadOnly[int] session_llm_count: ReadOnly[int] session_agent_count: ReadOnly[int] + session_models: ReadOnly[Sequence[str]] + + +_SESSION_MODELS_LIMIT: Final = 10 +_SESSION_MODEL_NAME_MAX_LEN: Final = 256 + + +class _SessionSpendStats(NamedTuple): + session_total_count: int + session_total_spend: float + mcp_tool_call_count: int + mcp_tool_call_spend: float + session_cache_hit_count: int + session_llm_count: int + session_agent_count: int + session_models: Sequence[str] + session_models_truncated: bool + + +_SessionSpendMap: TypeAlias = Mapping[tuple[str, str], _SessionSpendStats] class _SpendSumAggregate(TypedDict, total=False): @@ -4121,7 +4143,7 @@ async def _build_ui_spend_logs_response( } ) - session_spend_map: dict[tuple[str, str], dict[str, int | float]] = {} + session_spend_map: _SessionSpendMap = {} if enrich_session_counts and session_ids: from prisma.errors import PrismaError @@ -4139,40 +4161,60 @@ async def _build_ui_spend_logs_response( rows: Final[Sequence[_SessionSpendRow]] = await _query_raw( prisma_client, f""" - SELECT session_id, api_key, - COUNT(*)::int AS session_total_count, - COALESCE(SUM(spend), 0)::double precision AS session_total_spend, - COUNT(*) FILTER ( - WHERE call_type IN {_MCP_CALL_TYPES_SQL} - )::int AS mcp_tool_call_count, - COALESCE(SUM(spend) FILTER ( - WHERE call_type IN {_MCP_CALL_TYPES_SQL} - ), 0)::double precision AS mcp_tool_call_spend, - COUNT(*) FILTER (WHERE LOWER(cache_hit) = 'true')::int AS session_cache_hit_count, - COUNT(*) FILTER ( - WHERE call_type NOT IN {_MCP_CALL_TYPES_SQL} AND call_type != {_AGENT_CALL_TYPE_SQL} - )::int AS session_llm_count, - COUNT(*) FILTER (WHERE call_type = {_AGENT_CALL_TYPE_SQL})::int AS session_agent_count - FROM "LiteLLM_SpendLogs" - WHERE session_id = ANY($1::text[]) - AND api_key = ANY($2::text[]) - GROUP BY session_id, api_key + SELECT s.*, COALESCE(m.session_models, ARRAY[]::text[]) AS session_models + FROM ( + SELECT session_id, api_key, + COUNT(*)::int AS session_total_count, + COALESCE(SUM(spend), 0)::double precision AS session_total_spend, + COUNT(*) FILTER ( + WHERE call_type IN {_MCP_CALL_TYPES_SQL} + )::int AS mcp_tool_call_count, + COALESCE(SUM(spend) FILTER ( + WHERE call_type IN {_MCP_CALL_TYPES_SQL} + ), 0)::double precision AS mcp_tool_call_spend, + COUNT(*) FILTER (WHERE LOWER(cache_hit) = 'true')::int AS session_cache_hit_count, + COUNT(*) FILTER ( + WHERE call_type NOT IN {_MCP_CALL_TYPES_SQL} AND call_type != {_AGENT_CALL_TYPE_SQL} + )::int AS session_llm_count, + COUNT(*) FILTER (WHERE call_type = {_AGENT_CALL_TYPE_SQL})::int AS session_agent_count + FROM "LiteLLM_SpendLogs" + WHERE session_id = ANY($1::text[]) + AND api_key = ANY($2::text[]) + GROUP BY session_id, api_key + ) s + LEFT JOIN LATERAL ( + SELECT ARRAY_AGG(d.model ORDER BY d.model) AS session_models + FROM ( + SELECT DISTINCT LEFT(model, $3::int) AS model + FROM "LiteLLM_SpendLogs" + WHERE session_id = s.session_id + AND api_key = s.api_key + AND model IS NOT NULL AND model <> '' + ORDER BY 1 + LIMIT $4::int + ) d + ) m ON TRUE """, session_ids, authorized_api_keys, + _SESSION_MODEL_NAME_MAX_LEN, + _SESSION_MODELS_LIMIT + 1, ) session_spend_map = { - (row["session_id"], row["api_key"]): { - "session_total_count": int(row.get("session_total_count") or 0), - "session_total_spend": float(row.get("session_total_spend") or 0.0), - "mcp_tool_call_count": int(row.get("mcp_tool_call_count") or 0), - "mcp_tool_call_spend": float(row.get("mcp_tool_call_spend") or 0.0), - "session_cache_hit_count": int(row.get("session_cache_hit_count") or 0), - "session_llm_count": int(row.get("session_llm_count") or 0), - "session_agent_count": int(row.get("session_agent_count") or 0), - } + (row["session_id"], row["api_key"]): _SessionSpendStats( + session_total_count=int(row.get("session_total_count") or 0), + session_total_spend=float(row.get("session_total_spend") or 0.0), + mcp_tool_call_count=int(row.get("mcp_tool_call_count") or 0), + mcp_tool_call_spend=float(row.get("mcp_tool_call_spend") or 0.0), + session_cache_hit_count=int(row.get("session_cache_hit_count") or 0), + session_llm_count=int(row.get("session_llm_count") or 0), + session_agent_count=int(row.get("session_agent_count") or 0), + session_models=models[:_SESSION_MODELS_LIMIT], + session_models_truncated=len(models) > _SESSION_MODELS_LIMIT, + ) for row in rows if row.get("session_id") and row.get("api_key") is not None + for models in (TypeAdapter(list[str]).validate_python(row.get("session_models") or ()),) } except PrismaError: verbose_proxy_logger.debug( @@ -4187,15 +4229,17 @@ async def _build_ui_spend_logs_response( sid = row_dict.get("session_id") row_api_key = row_dict.get("api_key") session_stats = session_spend_map.get((sid, row_api_key)) if sid and row_api_key is not None else None - row_dict["session_total_count"] = int(session_stats["session_total_count"]) if session_stats else 1 + row_dict["session_total_count"] = session_stats.session_total_count if session_stats else 1 if session_stats: - row_dict["session_total_spend"] = session_stats["session_total_spend"] - if session_stats["mcp_tool_call_count"]: - row_dict["mcp_tool_call_count"] = session_stats["mcp_tool_call_count"] - row_dict["mcp_tool_call_spend"] = session_stats["mcp_tool_call_spend"] - row_dict["session_cache_hit_count"] = session_stats["session_cache_hit_count"] - row_dict["session_llm_count"] = session_stats["session_llm_count"] - row_dict["session_agent_count"] = session_stats["session_agent_count"] + row_dict["session_total_spend"] = session_stats.session_total_spend + if session_stats.mcp_tool_call_count: + row_dict["mcp_tool_call_count"] = session_stats.mcp_tool_call_count + row_dict["mcp_tool_call_spend"] = session_stats.mcp_tool_call_spend + row_dict["session_cache_hit_count"] = session_stats.session_cache_hit_count + row_dict["session_llm_count"] = session_stats.session_llm_count + row_dict["session_agent_count"] = session_stats.session_agent_count + row_dict["session_models"] = session_stats.session_models + row_dict["session_models_truncated"] = session_stats.session_models_truncated enriched.append(row_dict) response_data: list = enriched else: diff --git a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py index 7e2e680743f..68704f476b5 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py @@ -17505,6 +17505,16 @@ def test_generate_key_request_blank_team_id_is_personal(): assert GenerateKeyRequest(team_id="team-1").team_id == "team-1" +def test_key_request_blank_organization_id_is_unset(): + from litellm.proxy._types import RegenerateKeyRequest, UpdateKeyRequest + + assert GenerateKeyRequest(organization_id="").organization_id is None + assert RegenerateKeyRequest(organization_id="").organization_id is None + assert UpdateKeyRequest(key="sk-1", organization_id="").organization_id is None + assert GenerateKeyRequest(organization_id="org-1").organization_id == "org-1" + assert UpdateKeyRequest(key="sk-1", organization_id="org-1").organization_id == "org-1" + + def test_key_generation_check_blank_team_id_uses_personal_permissions(monkeypatch): """key_generation_check with team_id="" must take the personal-key path instead of failing the team lookup with "Unable to find team object" (LIT-3925).""" diff --git a/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py index 4661cc17dbc..5fa59a85c9d 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py @@ -1,5 +1,6 @@ import inspect import asyncio +import contextlib import json from typing import Dict, Optional from unittest.mock import AsyncMock, MagicMock, patch @@ -875,6 +876,7 @@ class TestDeleteModelClearsRouterRegistry: mock_prisma = MagicMock() mock_prisma.db = MagicMock() mock_prisma.db.litellm_proxymodeltable = AsyncMock() + mock_prisma.db.query_raw = AsyncMock(return_value=[]) mock_prisma.db.litellm_proxymodeltable.find_unique = AsyncMock(return_value=db_row) mock_prisma.db.litellm_proxymodeltable.delete = AsyncMock(return_value=db_row) @@ -936,6 +938,7 @@ class TestDeleteModelClearsRouterRegistry: mock_prisma = MagicMock() mock_prisma.db = MagicMock() mock_prisma.db.litellm_proxymodeltable = AsyncMock() + mock_prisma.db.query_raw = AsyncMock(return_value=[]) mock_prisma.db.litellm_proxymodeltable.find_unique = AsyncMock(return_value=db_row) mock_prisma.db.litellm_proxymodeltable.delete = AsyncMock(return_value=db_row) @@ -2079,6 +2082,7 @@ class TestAddAndDeleteModelLifecycle: mock_prisma = MagicMock() mock_prisma.db = MagicMock() mock_prisma.db.litellm_proxymodeltable = AsyncMock() + mock_prisma.db.query_raw = AsyncMock(return_value=[]) mock_prisma.db.litellm_proxymodeltable.create = AsyncMock(return_value=db_row) mock_prisma.db.litellm_proxymodeltable.find_unique = AsyncMock( return_value=db_row @@ -2191,6 +2195,7 @@ class TestDeleteTeamBYOKModelGhost: mock_prisma = MagicMock() mock_prisma.db = MagicMock() mock_prisma.db.litellm_proxymodeltable = AsyncMock() + mock_prisma.db.query_raw = AsyncMock(return_value=[]) mock_prisma.db.litellm_proxymodeltable.find_unique = AsyncMock( return_value=db_row ) @@ -2273,6 +2278,7 @@ class TestDeleteTeamBYOKModelGhost: mock_prisma = MagicMock() mock_prisma.db = MagicMock() mock_prisma.db.litellm_proxymodeltable = AsyncMock() + mock_prisma.db.query_raw = AsyncMock(return_value=[]) mock_prisma.db.litellm_proxymodeltable.find_unique = AsyncMock( return_value=db_row ) @@ -2349,6 +2355,7 @@ class TestDeleteTeamBYOKModelGhost: mock_prisma = MagicMock() mock_prisma.db = MagicMock() mock_prisma.db.litellm_proxymodeltable = AsyncMock() + mock_prisma.db.query_raw = AsyncMock(return_value=[]) mock_prisma.db.litellm_proxymodeltable.find_unique = AsyncMock( return_value=deleted_row ) @@ -2434,6 +2441,7 @@ class TestDeleteTeamBYOKModelGhost: mock_prisma = MagicMock() mock_prisma.db = MagicMock() mock_prisma.db.litellm_proxymodeltable = AsyncMock() + mock_prisma.db.query_raw = AsyncMock(return_value=[]) mock_prisma.db.litellm_proxymodeltable.find_unique = AsyncMock( return_value=db_row ) @@ -2515,6 +2523,7 @@ class TestDeleteTeamBYOKModelGhost: mock_prisma = MagicMock() mock_prisma.db = MagicMock() mock_prisma.db.litellm_proxymodeltable = AsyncMock() + mock_prisma.db.query_raw = AsyncMock(return_value=[]) mock_prisma.db.litellm_proxymodeltable.find_unique = AsyncMock( return_value=db_row ) @@ -2584,6 +2593,7 @@ class TestDeleteModelTeamAuth: mock_prisma = MagicMock() mock_prisma.db = MagicMock() mock_prisma.db.litellm_proxymodeltable = AsyncMock() + mock_prisma.db.query_raw = AsyncMock(return_value=[]) mock_prisma.db.litellm_proxymodeltable.find_unique = AsyncMock( return_value=db_row ) @@ -2702,6 +2712,7 @@ class TestDeleteModelTeamAuth: mock_prisma = MagicMock() mock_prisma.db = MagicMock() mock_prisma.db.litellm_proxymodeltable = AsyncMock() + mock_prisma.db.query_raw = AsyncMock(return_value=[]) mock_prisma.db.litellm_proxymodeltable.find_unique = AsyncMock( return_value=db_row ) @@ -3844,6 +3855,7 @@ class TestDeleteEvictionsHoldTheReconcileLock: prisma = MagicMock() prisma.db.litellm_proxymodeltable = table + prisma.db.query_raw = AsyncMock(return_value=[]) router = MagicMock() router.delete_deployment = MagicMock(return_value=True) @@ -4654,3 +4666,179 @@ class TestBlockModelResponseSerialization: assert body["model_id"] == "m-block-1" assert body["blocked"] is blocked assert body["litellm_params"] == {"model": "openai/gpt-4o-mini", "api_key": "encrypted-value"} + + +class TestAccessGroupModelSync: + """A rename or delete of a deployment must land in every unified access group that names it.""" + + _PS = "litellm.proxy.proxy_server" + _MOD = "litellm.proxy.management_endpoints.model_management_endpoints" + _INVALIDATE = "litellm.proxy.management_helpers.access_group_model_sync.invalidate_access_group_caches" + + @staticmethod + def _admin(): + return UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-admin") + + @staticmethod + def _prisma_with_row(model_id: str, model_name: str, deployment_count: int): + row = LiteLLM_ProxyModelTable( + model_id=model_id, + model_name=model_name, + litellm_params={"model": "openai/gpt-5.6"}, + model_info={"id": model_id}, + created_by="admin", + updated_by="admin", + ) + + async def query_raw(sql, *params): + if sql.startswith("SELECT COUNT(*)"): + return [{"deployment_count": deployment_count}] + return [{"access_group_id": "ag-1"}] + + mock_prisma = MagicMock() + mock_prisma.db = MagicMock() + mock_prisma.db.query_raw = AsyncMock(side_effect=query_raw) + mock_prisma.db.litellm_proxymodeltable = AsyncMock() + mock_prisma.db.litellm_proxymodeltable.find_unique = AsyncMock(return_value=row) + mock_prisma.db.litellm_proxymodeltable.update = AsyncMock(return_value=row) + mock_prisma.db.litellm_proxymodeltable.delete = AsyncMock(return_value=row) + return mock_prisma + + @staticmethod + def _access_group_updates(mock_prisma): + return [ + call + for call in mock_prisma.db.query_raw.await_args_list + if call.args[0].startswith('UPDATE "LiteLLM_AccessGroupTable"') + ] + + @contextlib.contextmanager + def _endpoint_env(self, mock_prisma, router): + with contextlib.ExitStack() as stack: + for target in ( + patch(f"{self._PS}.prisma_client", mock_prisma), + patch(f"{self._PS}.llm_router", router), + patch(f"{self._PS}.store_model_in_db", True), + patch(f"{self._PS}.premium_user", True), + patch(f"{self._PS}.proxy_logging_obj", MagicMock()), + patch(f"{self._PS}.user_api_key_cache", MagicMock()), + patch(f"{self._MOD}.ModelManagementAuthChecks.can_user_make_model_call", new=AsyncMock(return_value=None)), + patch( + f"{self._MOD}.clear_cache", + new=AsyncMock(return_value=ReconcileOutcome(still_desired=None, live_after=None)), + ), + patch(f"{self._MOD}.encrypt_value_helper", side_effect=lambda value, **kwargs: value), + ): + stack.enter_context(target) + yield stack.enter_context(patch(self._INVALIDATE, new=AsyncMock())) + + @pytest.mark.asyncio + async def test_patch_model_rename_rewrites_the_groups_that_named_the_model(self): + from litellm.proxy.management_endpoints.model_management_endpoints import patch_model + + mock_prisma = self._prisma_with_row("m-rename", "gpt-5.6", deployment_count=0) + router = MagicMock() + router.get_model_ids.return_value = ["m-rename"] + + with self._endpoint_env(mock_prisma, router) as invalidate: + await patch_model( + model_id="m-rename", + patch_data=updateDeployment(model_name="gpt-5.6-eu"), + user_api_key_dict=self._admin(), + ) + + written = mock_prisma.db.litellm_proxymodeltable.update.await_args.kwargs["data"] + assert written["model_name"] == "gpt-5.6-eu" + (update_call,) = self._access_group_updates(mock_prisma) + assert "array_replace" in update_call.args[0] + assert update_call.args[1:] == ("gpt-5.6", "gpt-5.6-eu") + invalidate.assert_awaited_once_with(("ag-1",)) + + @pytest.mark.asyncio + async def test_patch_model_rename_appends_when_a_sibling_deployment_keeps_the_old_name(self): + from litellm.proxy.management_endpoints.model_management_endpoints import patch_model + + mock_prisma = self._prisma_with_row("m-rename", "gpt-5.6", deployment_count=1) + router = MagicMock() + router.get_model_ids.return_value = ["m-rename"] + + with self._endpoint_env(mock_prisma, router): + await patch_model( + model_id="m-rename", + patch_data=updateDeployment(model_name="gpt-5.6-eu"), + user_api_key_dict=self._admin(), + ) + + (update_call,) = self._access_group_updates(mock_prisma) + assert "array_append" in update_call.args[0] + assert update_call.args[1:] == ("gpt-5.6", "gpt-5.6-eu") + + @pytest.mark.asyncio + async def test_patch_model_without_a_rename_leaves_access_groups_alone(self): + from litellm.proxy.management_endpoints.model_management_endpoints import patch_model + + mock_prisma = self._prisma_with_row("m-same", "gpt-5.6", deployment_count=0) + router = MagicMock() + router.get_model_ids.return_value = ["m-same"] + + with self._endpoint_env(mock_prisma, router) as invalidate: + await patch_model(model_id="m-same", patch_data=updateDeployment(blocked=True), user_api_key_dict=self._admin()) + + mock_prisma.db.query_raw.assert_not_awaited() + invalidate.assert_not_awaited() + + @pytest.mark.asyncio + async def test_delete_model_drops_the_name_from_groups_when_nothing_backs_it(self): + from litellm.proxy.management_endpoints.model_management_endpoints import ModelInfoDelete, delete_model + + mock_prisma = self._prisma_with_row("m-doomed", "gpt-5.6", deployment_count=0) + router = MagicMock() + router.get_model_ids.return_value = [] + + with self._endpoint_env(mock_prisma, router) as invalidate: + await delete_model(model_info=ModelInfoDelete(id="m-doomed"), user_api_key_dict=self._admin()) + + (update_call,) = self._access_group_updates(mock_prisma) + assert "array_remove" in update_call.args[0] + assert update_call.args[1:] == ("gpt-5.6",) + invalidate.assert_awaited_once_with(("ag-1",)) + + @pytest.mark.asyncio + async def test_delete_model_keeps_the_name_while_a_sibling_deployment_backs_it(self): + from litellm.proxy.management_endpoints.model_management_endpoints import ModelInfoDelete, delete_model + + mock_prisma = self._prisma_with_row("m-doomed", "gpt-5.6", deployment_count=1) + router = MagicMock() + router.get_model_ids.return_value = [] + + with self._endpoint_env(mock_prisma, router) as invalidate: + await delete_model(model_info=ModelInfoDelete(id="m-doomed"), user_api_key_dict=self._admin()) + + assert self._access_group_updates(mock_prisma) == [] + invalidate.assert_not_awaited() + + @pytest.mark.asyncio + async def test_update_model_persists_a_new_model_name_and_rewrites_the_groups(self): + from litellm.proxy.management_endpoints.model_management_endpoints import update_model + from litellm.types.router import ModelInfo, updateLiteLLMParams + + mock_prisma = self._prisma_with_row("m-terraform", "gpt-5.6", deployment_count=0) + router = MagicMock() + router.get_model_ids.return_value = ["m-terraform"] + + with self._endpoint_env(mock_prisma, router) as invalidate: + await update_model( + model_params=updateDeployment( + model_name="gpt-5.6-eu", + litellm_params=updateLiteLLMParams(model="openai/gpt-5.6"), + model_info=ModelInfo(id="m-terraform"), + ), + user_api_key_dict=self._admin(), + ) + + written = mock_prisma.db.litellm_proxymodeltable.update.await_args.kwargs["data"] + assert written["model_name"] == "gpt-5.6-eu" + (update_call,) = self._access_group_updates(mock_prisma) + assert "array_replace" in update_call.args[0] + assert update_call.args[1:] == ("gpt-5.6", "gpt-5.6-eu") + invalidate.assert_awaited_once_with(("ag-1",)) diff --git a/tests/test_litellm/proxy/management_helpers/test_access_group_model_sync.py b/tests/test_litellm/proxy/management_helpers/test_access_group_model_sync.py new file mode 100644 index 00000000000..65ef2d55cb8 --- /dev/null +++ b/tests/test_litellm/proxy/management_helpers/test_access_group_model_sync.py @@ -0,0 +1,170 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from litellm.proxy.db.prisma_client import PrismaWrapper +from litellm.proxy.db.routing_prisma_wrapper import RoutingPrismaWrapper +from litellm.proxy.management_helpers.access_group_model_sync import ( + sync_access_groups_for_deleted_model, + sync_access_groups_for_renamed_model, +) + +_INVALIDATE = "litellm.proxy.management_helpers.access_group_model_sync.invalidate_access_group_caches" + + +def _routed_prisma_client(deployment_count: int): + async def query_raw(sql, *params): + if sql.startswith("SELECT COUNT(*)"): + return [{"deployment_count": deployment_count}] + return [{"access_group_id": "ag-1"}, {"access_group_id": "ag-2"}] + + writer_inner = MagicMock(name="writer_prisma") + reader_inner = MagicMock(name="reader_prisma") + writer_inner.query_raw = AsyncMock(side_effect=query_raw) + reader_inner.query_raw = AsyncMock(side_effect=query_raw) + writer = PrismaWrapper(original_prisma=writer_inner, iam_token_db_auth=False) + reader = PrismaWrapper(original_prisma=reader_inner, iam_token_db_auth=False) + routing = RoutingPrismaWrapper(writer=writer, reader=reader) + return SimpleNamespace(db=routing), writer_inner, reader_inner + + +def _access_group_updates(writer_inner): + return [ + call + for call in writer_inner.query_raw.await_args_list + if call.args[0].startswith('UPDATE "LiteLLM_AccessGroupTable"') + ] + + +@pytest.mark.asyncio +async def test_rename_replaces_the_old_name_when_no_other_deployment_carries_it(): + prisma_client, writer_inner, reader_inner = _routed_prisma_client(deployment_count=0) + + with patch(_INVALIDATE, new=AsyncMock()) as invalidate: + await sync_access_groups_for_renamed_model( + prisma_client, model_id="m-1", old_name="gpt-5.6", new_name="gpt-5.6-eu", llm_router=None + ) + + (update_call,) = _access_group_updates(writer_inner) + assert "array_replace" in update_call.args[0] + assert update_call.args[1:] == ("gpt-5.6", "gpt-5.6-eu") + invalidate.assert_awaited_once_with(("ag-1", "ag-2")) + reader_inner.query_raw.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_rename_appends_the_new_name_when_a_sibling_row_keeps_the_old_one(): + prisma_client, writer_inner, _ = _routed_prisma_client(deployment_count=1) + + with patch(_INVALIDATE, new=AsyncMock()) as invalidate: + await sync_access_groups_for_renamed_model( + prisma_client, model_id="m-1", old_name="gpt-5.6", new_name="gpt-5.6-eu", llm_router=None + ) + + (update_call,) = _access_group_updates(writer_inner) + assert "array_append" in update_call.args[0] + assert "array_replace" not in update_call.args[0] + assert update_call.args[1:] == ("gpt-5.6", "gpt-5.6-eu") + invalidate.assert_awaited_once_with(("ag-1", "ag-2")) + + +def _router_serving(db_model_by_deployment_id: dict[str, bool]): + llm_router = MagicMock() + llm_router.get_model_ids.return_value = list(db_model_by_deployment_id) + llm_router.get_deployment.side_effect = lambda model_id: SimpleNamespace( + model_info=SimpleNamespace(db_model=db_model_by_deployment_id[model_id]) + ) + return llm_router + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "db_model_by_deployment_id, expected_write", + [ + ({"m-1": True}, "array_replace"), + ({"m-1": True, "m-from-config": False}, "array_append"), + ({"m-1": True, "m-db-sibling-this-worker-has-not-refreshed": True}, "array_replace"), + ], +) +async def test_rename_counts_only_config_deployments_with_another_id_as_backing_the_old_name( + db_model_by_deployment_id, expected_write +): + prisma_client, writer_inner, _ = _routed_prisma_client(deployment_count=0) + llm_router = _router_serving(db_model_by_deployment_id) + + with patch(_INVALIDATE, new=AsyncMock()): + await sync_access_groups_for_renamed_model( + prisma_client, model_id="m-1", old_name="gpt-5.6", new_name="gpt-5.6-eu", llm_router=llm_router + ) + + llm_router.get_model_ids.assert_called_once_with(model_name="gpt-5.6") + (update_call,) = _access_group_updates(writer_inner) + assert expected_write in update_call.args[0] + + +@pytest.mark.asyncio +async def test_delete_ignores_a_db_sibling_this_worker_has_not_refreshed_yet(): + prisma_client, writer_inner, _ = _routed_prisma_client(deployment_count=0) + llm_router = _router_serving({"m-1": True, "m-renamed-elsewhere": True}) + + with patch(_INVALIDATE, new=AsyncMock()) as invalidate: + await sync_access_groups_for_deleted_model( + prisma_client, model_id="m-1", model_name="gpt-5.6", llm_router=llm_router + ) + + (update_call,) = _access_group_updates(writer_inner) + assert "array_remove" in update_call.args[0] + invalidate.assert_awaited_once_with(("ag-1", "ag-2")) + + +@pytest.mark.asyncio +async def test_delete_keeps_the_name_while_a_config_deployment_still_serves_it(): + prisma_client, writer_inner, _ = _routed_prisma_client(deployment_count=0) + llm_router = _router_serving({"m-1": True, "m-from-config": False}) + + with patch(_INVALIDATE, new=AsyncMock()) as invalidate: + await sync_access_groups_for_deleted_model( + prisma_client, model_id="m-1", model_name="gpt-5.6", llm_router=llm_router + ) + + assert _access_group_updates(writer_inner) == [] + invalidate.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_rename_to_the_same_name_writes_nothing(): + prisma_client, writer_inner, _ = _routed_prisma_client(deployment_count=0) + + with patch(_INVALIDATE, new=AsyncMock()) as invalidate: + await sync_access_groups_for_renamed_model( + prisma_client, model_id="m-1", old_name="gpt-5.6", new_name="gpt-5.6", llm_router=None + ) + + assert _access_group_updates(writer_inner) == [] + invalidate.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_delete_removes_the_name_when_no_row_backs_it_any_more(): + prisma_client, writer_inner, reader_inner = _routed_prisma_client(deployment_count=0) + + with patch(_INVALIDATE, new=AsyncMock()) as invalidate: + await sync_access_groups_for_deleted_model(prisma_client, model_id="m-1", model_name="gpt-5.6", llm_router=None) + + (update_call,) = _access_group_updates(writer_inner) + assert "array_remove" in update_call.args[0] + assert update_call.args[1:] == ("gpt-5.6",) + invalidate.assert_awaited_once_with(("ag-1", "ag-2")) + reader_inner.query_raw.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_delete_keeps_the_name_while_a_sibling_row_still_backs_it(): + prisma_client, writer_inner, _ = _routed_prisma_client(deployment_count=2) + + with patch(_INVALIDATE, new=AsyncMock()) as invalidate: + await sync_access_groups_for_deleted_model(prisma_client, model_id="m-1", model_name="gpt-5.6", llm_router=None) + + assert _access_group_updates(writer_inner) == [] + invalidate.assert_not_awaited() diff --git a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py index 30b086bab61..c8b35e8a841 100644 --- a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py +++ b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py @@ -3971,6 +3971,7 @@ async def test_build_ui_spend_logs_response_dict_rows_session_counts(): "mcp_tool_call_spend": 10.0, "session_llm_count": 1, "session_agent_count": 0, + "session_models": ["claude-haiku-4-5", "gpt-5.4-nano"], } ] ) @@ -3997,6 +3998,7 @@ async def test_build_ui_spend_logs_response_dict_rows_session_counts(): assert rows[1]["mcp_tool_call_spend"] == 10.0 assert rows[0]["session_llm_count"] == 1 assert rows[0]["session_agent_count"] == 0 + assert rows[0]["session_models"] == ["claude-haiku-4-5", "gpt-5.4-nano"] # Every row in the session carries the full session spend, not just its own assert rows[0]["session_total_spend"] == 15.0 @@ -4004,11 +4006,60 @@ async def test_build_ui_spend_logs_response_dict_rows_session_counts(): # Row without a session_id defaults to 1 assert rows[2]["session_total_count"] == 1 + assert "session_models" not in rows[2] # The count is folded into the single aggregate query; no separate group_by call. mock_prisma.db.litellm_spendlogs.group_by.assert_not_called() +@pytest.mark.asyncio +async def test_build_ui_spend_logs_response_caps_session_models(): + """The per-session model list is bounded server-side and flags when it was cut.""" + from litellm.proxy.spend_tracking.spend_management_endpoints import ( + _SESSION_MODELS_LIMIT, + _build_ui_spend_logs_response, + ) + + session_id = "sess-many-models" + api_key = "hashed-key-xyz" + over_limit_models = [f"model-{i:02d}" for i in range(_SESSION_MODELS_LIMIT + 1)] + + mock_prisma = MagicMock() + mock_prisma.db.query_raw = AsyncMock( + return_value=[ + { + "session_id": session_id, + "api_key": api_key, + "session_total_count": len(over_limit_models), + "session_total_spend": 1.0, + "mcp_tool_call_count": 0, + "mcp_tool_call_spend": 0.0, + "session_llm_count": len(over_limit_models), + "session_agent_count": 0, + "session_models": over_limit_models, + } + ] + ) + + result = await _build_ui_spend_logs_response( + prisma_client=mock_prisma, + data=[{"request_id": "req-1", "session_id": session_id, "call_type": "completion", "api_key": api_key}], + total_records=1, + page=1, + page_size=50, + total_pages=1, + enrich_session_counts=True, + ) + + row = result["data"][0] + assert row["session_models"] == over_limit_models[:_SESSION_MODELS_LIMIT] + assert row["session_models_truncated"] is True + + sql, *params = mock_prisma.db.query_raw.await_args.args + assert "LIMIT $4" in sql + assert params[3] == _SESSION_MODELS_LIMIT + 1 + + @pytest.mark.asyncio async def test_build_ui_spend_logs_response_key_split_session_gets_per_key_aggregates(): """ diff --git a/type-discipline-budget.json b/type-discipline-budget.json index d5bf3883be4..cbcb5dca443 100644 --- a/type-discipline-budget.json +++ b/type-discipline-budget.json @@ -1,6 +1,6 @@ { "LIT001": { - "limit": 22330 + "limit": 22328 }, "LIT002": { "limit": 26763 diff --git a/ui/litellm-dashboard/src/components/organisms/create_key_button.integration.test.tsx b/ui/litellm-dashboard/src/components/organisms/create_key_button.integration.test.tsx index 8630be8548f..045dcfa3ceb 100644 --- a/ui/litellm-dashboard/src/components/organisms/create_key_button.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.integration.test.tsx @@ -750,6 +750,19 @@ describe("CreateKey", () => { expect((await createdPayload()).organization_id).toBe("org-1"); }); + + it("drops organization_id when the chosen organization is cleared again", async () => { + state.organizations = [{ organization_id: "org-1", organization_alias: "Engineering" }]; + await openModal(); + await nameTheKey(); + + await userEvent.click(await screen.findByLabelText("Organization")); + await userEvent.click(await screen.findByRole("option", { name: /Engineering/ })); + await userEvent.click(await screen.findByRole("button", { name: "Clear" })); + await submit(); + + expect((await createdPayload()).organization_id).toBeUndefined(); + }); }); describe("policy and prompt fields", () => { diff --git a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx index d38a8995c1b..5749541dcea 100644 --- a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx +++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx @@ -588,7 +588,7 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp }; const changeOrganization = (write: FieldWrite) => (orgId: string) => { - write(orgId); + write(orgId || undefined); setSelectedOrganizationId(orgId || null); // Clear team and project when org changes setSelectedCreateKeyTeam(null); diff --git a/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx b/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx index 97b09e00808..10b1983b54b 100644 --- a/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx @@ -1457,6 +1457,32 @@ describe("KeyEditView", () => { expect(screen.getByLabelText("Organization")).toHaveValue("Engineering"); }); }); + + it("submits organization_id as null after the organization is cleared", async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + renderWithProviders( + {}} + onSubmit={onSubmit} + accessToken="" + userID="" + userRole="Admin" + premiumUser={false} + />, + ); + + await waitFor(() => { + expect(screen.getByLabelText("Organization")).toHaveValue("Engineering"); + }); + await userEvent.click(screen.getByRole("button", { name: "Clear" })); + await userEvent.click(screen.getByRole("button", { name: /save changes/i })); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ organization_id: null })); + }); + expect(JSON.parse(JSON.stringify(onSubmit.mock.calls[0][0]))).toHaveProperty("organization_id", null); + }); }); describe("models dropdown team gating", () => { diff --git a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx index df1af2ca8e9..3e772fd0e9b 100644 --- a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx @@ -303,8 +303,8 @@ export function KeyEditView({ } }; - const handleOrganizationChange = (setField: (value: string | undefined) => void, orgId: string | undefined) => { - setField(orgId); + const handleOrganizationChange = (setField: (value: string | null) => void, orgId: string | undefined) => { + setField(orgId || null); setSelectedOrganizationId(orgId || null); form.setValue("team_id", undefined); }; diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsTableColumns.test.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsTableColumns.test.tsx index ce59c62f1c4..e3bacc0908a 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsTableColumns.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsTableColumns.test.tsx @@ -75,6 +75,68 @@ describe("Cost column", () => { }); }); +describe("Type column", () => { + it("shows the conversation badge and composition even when an MCP call represents the conversation", async () => { + const user = userEvent.setup(); + const mcpRepresentative = { + request_id: "req-mcp-rep", + call_type: "call_mcp_tool", + session_id: "sess-edge", + session_total_count: 3, + session_llm_count: 2, + mcp_tool_call_count: 1, + session_agent_count: 0, + }; + renderRows([logEntry(mcpRepresentative)]); + + expect(screen.queryByText("MCP")).not.toBeInTheDocument(); + await user.hover(screen.getByText("3")); + expect(await screen.findByText("2 LLM • 1 MCP")).toBeInTheDocument(); + }); + + it("keeps the plain MCP badge for a single MCP call", () => { + renderRows([logEntry({ request_id: "req-mcp-solo", call_type: "call_mcp_tool", session_total_count: 1 })]); + + expect(screen.getByText("MCP")).toBeInTheDocument(); + }); +}); + +describe("Model column", () => { + it("lists every model used across a conversation, not only the representative call's model", () => { + const conversationCall: Partial = { + request_id: "req-session", + model: "gpt-5.6", + session_id: "sess-1", + session_total_count: 3, + session_models: ["claude-sonnet-5", "gpt-5.6"], + }; + renderRows([logEntry(conversationCall)]); + + expect(screen.getByText("claude-sonnet-5, gpt-5.6")).toBeInTheDocument(); + expect(screen.queryByText("gpt-5.6")).not.toBeInTheDocument(); + }); + + it("marks a conversation whose model list was capped by the server", () => { + const cappedCall = { + request_id: "req-capped", + model: "gpt-5.6", + session_id: "sess-2", + session_total_count: 30, + session_models: ["claude-sonnet-5", "gpt-5.6"], + session_models_truncated: true, + }; + renderRows([logEntry(cappedCall)]); + + expect(screen.getByText("claude-sonnet-5, gpt-5.6, ...")).toBeInTheDocument(); + }); + + it("keeps a single call's own model", () => { + renderRows([logEntry({ request_id: "req-single", model: "gpt-5.6" })]); + + expect(screen.getByText("gpt-5.6")).toBeInTheDocument(); + }); +}); + describe("row action cells", () => { it("reports the key hash through the injected dependency rather than a row field", async () => { const user = userEvent.setup(); diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsTableColumns.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsTableColumns.tsx index b9058d02a6b..8db0b106851 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsTableColumns.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsTableColumns.tsx @@ -63,9 +63,11 @@ export const getRequestLogsTableColumns = ({ const sessionAgentCount = log.session_agent_count ?? (isAgent ? sessionCount : 0); const sessionMcpCount = log.mcp_tool_call_count ?? (isMcp ? sessionCount : 0); - if (isMcp) return ; - if (isAgent && sessionCount <= 1) return ; - if (sessionCount <= 1) return ; + if (sessionCount <= 1) { + if (isMcp) return ; + if (isAgent) return ; + return ; + } const sessionTypeBadge = ( @@ -224,10 +226,13 @@ export const getRequestLogsTableColumns = ({ cell: ({ row }) => { const log = row.original; const provider = log.custom_llm_provider; - const modelName = log.model ?? ""; + const sessionModels = log.session_models ?? []; + const modelNames = sessionModels.length > 0 ? sessionModels : [log.model ?? ""]; + const modelLabel = log.session_models_truncated ? `${modelNames.join(", ")}, ...` : modelNames.join(", "); + const isSingleModel = modelNames.length === 1; return (
- {provider && ( + {provider && isSingleModel && ( )} - {modelName}} /> + + {modelLabel} + + } + />
); }, diff --git a/ui/litellm-dashboard/src/components/view_logs/columns.tsx b/ui/litellm-dashboard/src/components/view_logs/columns.tsx index 2f3a3681352..21e09faf454 100644 --- a/ui/litellm-dashboard/src/components/view_logs/columns.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/columns.tsx @@ -47,4 +47,6 @@ export type LogEntry = { mcp_tool_call_spend?: number; session_llm_count?: number; session_agent_count?: number; + session_models?: string[]; + session_models_truncated?: boolean; };