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 <yassin@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Mateo Wang 2026-09-03 08:58:48 -07:00 committed by GitHub
parent 1de960bce7
commit 34d4f7f8ae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 797 additions and 57 deletions

View file

@ -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:

View file

@ -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()

View file

@ -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)

View file

@ -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:

View file

@ -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)."""

View file

@ -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",))

View file

@ -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()

View file

@ -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():
"""

View file

@ -1,6 +1,6 @@
{
"LIT001": {
"limit": 22330
"limit": 22328
},
"LIT002": {
"limit": 26763

View file

@ -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", () => {

View file

@ -588,7 +588,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ 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);

View file

@ -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(
<KeyEditView
keyData={{ ...MOCK_KEY_DATA, organization_id: "org-1" }}
onCancel={() => {}}
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", () => {

View file

@ -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);
};

View file

@ -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<LogEntry> = {
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();

View file

@ -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 <McpBadge />;
if (isAgent && sessionCount <= 1) return <AgentBadge />;
if (sessionCount <= 1) return <LlmBadge />;
if (sessionCount <= 1) {
if (isMcp) return <McpBadge />;
if (isAgent) return <AgentBadge />;
return <LlmBadge />;
}
const sessionTypeBadge = (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-info/10 text-info border border-info/20 rounded-full text-[11px] font-medium whitespace-nowrap">
@ -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 (
<div className="flex items-center space-x-2">
{provider && (
{provider && isSingleModel && (
<img
src={getLogoUrl(log, provider)}
alt=""
@ -237,7 +242,14 @@ export const getRequestLogsTableColumns = ({
}}
/>
)}
<CellTooltip content={modelName} trigger={<span className="max-w-[15ch] truncate block">{modelName}</span>} />
<CellTooltip
content={modelLabel}
trigger={
<span className={isSingleModel ? "max-w-[15ch] truncate block" : "min-w-0 truncate block"}>
{modelLabel}
</span>
}
/>
</div>
);
},

View file

@ -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;
};