From 34d4f7f8aef2951ffcf5ee04f33bff69aab4fd9d Mon Sep 17 00:00:00 2001 From: Mateo Wang <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 3 Sep 2026 08:58:48 -0700 Subject: [PATCH] fix: 1.99.0-rc2 UI bug batch (empty org on key create, session pagination, access group rename/delete) (#39436) * fix(ui): clearing the organization picker no longer sends organization_id="" on key create * fix(proxy): paginate Request Logs by conversation and aggregate session type counts and models server-side * fix(proxy): keep access groups in sync when a model is renamed or deleted * fix(proxy): cap the Request Logs conversation total like the row total * fix(proxy): judge access group backing by the database for db models A worker whose router has not polled the database yet still lists a sibling under its old name, so a delete or rename handled there kept the stale name in every access group. Only config-sourced deployments count as router backing now; db models are counted in the table. * fix(ui): keep the conversation badge when an MCP call represents a conversation A conversation that straddles the bounded page window can be represented by one of its MCP rows, which showed a plain MCP badge and hid the session counts. The badge now reads the server aggregates whenever the conversation has more than one call. * fix(proxy): list every model of a conversation in Request Logs and keep the conversation badge for MCP representatives Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(proxy): type session spend aggregates Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): satisfy request logs lint budget Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(proxy): cap per-session model aggregation in request logs Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * chore: ratchet type-discipline budget after staging merge Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): send an explicit null when the key edit form clears the organization Clearing the Organization picker in the key edit form wrote undefined into the form value, and JSON.stringify drops undefined-valued keys, so /key/update never saw the field and the key kept its old organization. Writing null instead survives serialization, and the backend's model_dump(exclude_unset=True) preserves it, so the column is set to NULL. --------- Co-authored-by: yassin Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/_types.py | 11 +- .../model_management_endpoints.py | 54 ++++- .../access_group_model_sync.py | 119 +++++++++++ .../spend_management_endpoints.py | 116 +++++++---- .../test_key_management_endpoints.py | 10 + .../test_model_management_endpoints.py | 188 ++++++++++++++++++ .../test_access_group_model_sync.py | 170 ++++++++++++++++ .../test_spend_management_endpoints.py | 51 +++++ type-discipline-budget.json | 2 +- .../create_key_button.integration.test.tsx | 13 ++ .../organisms/create_key_button.tsx | 2 +- .../templates/key_edit_view.test.tsx | 26 +++ .../components/templates/key_edit_view.tsx | 4 +- .../RequestLogsTableColumns.test.tsx | 62 ++++++ .../view_logs/RequestLogsTableColumns.tsx | 24 ++- .../src/components/view_logs/columns.tsx | 2 + 16 files changed, 797 insertions(+), 57 deletions(-) create mode 100644 litellm/proxy/management_helpers/access_group_model_sync.py create mode 100644 tests/test_litellm/proxy/management_helpers/test_access_group_model_sync.py 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; };