([]);
@@ -50,10 +53,16 @@ export function MCPServerPermissions({
});
};
+ const directServerIds = mcpServers.filter(
+ (server) => server !== NO_MCP_SERVERS_SENTINEL && server !== ALL_PROXY_MCP_SERVERS_SENTINEL,
+ );
+ const inheritedOnlyServers = inheritedMcpServers.filter((grant) => !mcpServers.includes(grant.id));
+ const serverIdCount = directServerIds.length + inheritedOnlyServers.length;
+
// Fetch MCP server details when component mounts
useEffect(() => {
const fetchMCPServerDetails = async () => {
- if (accessToken && mcpServers.length > 0) {
+ if (accessToken && serverIdCount > 0) {
try {
const response = await fetchMCPServers(accessToken);
if (response && Array.isArray(response)) {
@@ -67,7 +76,7 @@ export function MCPServerPermissions({
}
};
fetchMCPServerDetails();
- }, [accessToken, mcpServers.length]);
+ }, [accessToken, serverIdCount]);
// Fetch toolset details
useEffect(() => {
@@ -98,12 +107,14 @@ export function MCPServerPermissions({
const blocksAllMcpServers = mcpServers.includes(NO_MCP_SERVERS_SENTINEL);
const grantsAllProxyMcpServers = mcpServers.includes(ALL_PROXY_MCP_SERVERS_SENTINEL);
- // Merge servers and access groups into one list
const mergedItems = [
- ...mcpServers
- .filter((server) => server !== NO_MCP_SERVERS_SENTINEL && server !== ALL_PROXY_MCP_SERVERS_SENTINEL)
- .map((server) => ({ type: "server", value: server })),
- ...mcpAccessGroups.map((group) => ({ type: "accessGroup", value: group })),
+ ...directServerIds.map((server) => ({ type: "server", value: server, tooltip: `Full ID: ${server}` })),
+ ...inheritedOnlyServers.map((grant) => ({
+ type: "server",
+ value: grant.id,
+ tooltip: inheritedGrantTooltip(grant),
+ })),
+ ...mcpAccessGroups.map((group) => ({ type: "accessGroup", value: group, tooltip: "" })),
];
const totalCount = mergedItems.length + mcpToolsets.length;
@@ -153,7 +164,7 @@ export function MCPServerPermissions({
{getMCPServerDisplayName(item.value)}
- {`Full ID: ${item.value}`}
+ {item.tooltip}
) : (
diff --git a/ui/litellm-dashboard/src/components/permissions/inheritedGrants.test.ts b/ui/litellm-dashboard/src/components/permissions/inheritedGrants.test.ts
new file mode 100644
index 00000000000..766f8f23f0b
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/permissions/inheritedGrants.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, it } from "vitest";
+import { computeInheritedGrants, inheritedGrantTooltip } from "./inheritedGrants";
+import { TeamAccessGroupModelGrant } from "../team/teamModelAccess";
+
+const GRANTS: TeamAccessGroupModelGrant[] = [
+ { access_group_id: "ag-1", access_group_name: "platform-tools", models: [], mcp_server_ids: ["mcp-1", "mcp-2"] },
+ {
+ access_group_id: "ag-2",
+ access_group_name: "support",
+ models: [],
+ mcp_server_ids: ["mcp-2"],
+ agent_ids: ["agent-1"],
+ },
+];
+
+describe("computeInheritedGrants", () => {
+ it("attributes each id to every group that grants it, in group order", () => {
+ expect(computeInheritedGrants(["mcp-1", "mcp-2"], GRANTS, (g) => g.mcp_server_ids)).toEqual([
+ { id: "mcp-1", accessGroupNames: ["platform-tools"] },
+ { id: "mcp-2", accessGroupNames: ["platform-tools", "support"] },
+ ]);
+ });
+
+ it("keeps ids the flat list carries but no group detail explains, with no group names", () => {
+ expect(computeInheritedGrants(["agent-1", "agent-legacy"], GRANTS, (g) => g.agent_ids)).toEqual([
+ { id: "agent-1", accessGroupNames: ["support"] },
+ { id: "agent-legacy", accessGroupNames: [] },
+ ]);
+ });
+
+ it("falls back to the group details when the flat list is missing, without duplicates", () => {
+ expect(computeInheritedGrants(undefined, GRANTS, (g) => g.mcp_server_ids).map((g) => g.id)).toEqual([
+ "mcp-1",
+ "mcp-2",
+ ]);
+ });
+
+ it("returns nothing when neither source has ids", () => {
+ expect(computeInheritedGrants(undefined, undefined, (g) => g.agent_ids)).toEqual([]);
+ });
+});
+
+describe("inheritedGrantTooltip", () => {
+ it("names a single group", () => {
+ expect(inheritedGrantTooltip({ id: "mcp-1", accessGroupNames: ["platform-tools"] })).toBe(
+ "Granted via access group platform-tools. Full ID: mcp-1",
+ );
+ });
+
+ it("lists several groups", () => {
+ expect(inheritedGrantTooltip({ id: "mcp-2", accessGroupNames: ["platform-tools", "support"] })).toBe(
+ "Granted via access groups platform-tools, support. Full ID: mcp-2",
+ );
+ });
+
+ it("stays generic when the proxy did not say which group granted it", () => {
+ expect(inheritedGrantTooltip({ id: "agent-legacy", accessGroupNames: [] })).toBe(
+ "Granted via an access group. Full ID: agent-legacy",
+ );
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/permissions/inheritedGrants.ts b/ui/litellm-dashboard/src/components/permissions/inheritedGrants.ts
new file mode 100644
index 00000000000..fb78de5ed77
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/permissions/inheritedGrants.ts
@@ -0,0 +1,26 @@
+import { describeGroups, TeamAccessGroupModelGrant } from "../team/teamModelAccess";
+
+export interface InheritedGrant {
+ id: string;
+ accessGroupNames: string[];
+}
+
+export function computeInheritedGrants(
+ ids: string[] | undefined,
+ grants: TeamAccessGroupModelGrant[] | undefined,
+ idsOf: (grant: TeamAccessGroupModelGrant) => string[] | undefined,
+): InheritedGrant[] {
+ const known = grants ?? [];
+ const allIds = [...new Set([...(ids ?? []), ...known.flatMap((grant) => idsOf(grant) ?? [])])];
+ return allIds.map((id) => ({
+ id,
+ accessGroupNames: known
+ .filter((grant) => (idsOf(grant) ?? []).includes(id))
+ .map((grant) => grant.access_group_name),
+ }));
+}
+
+export const inheritedGrantTooltip = (grant: InheritedGrant): string => {
+ const source = grant.accessGroupNames.length > 0 ? describeGroups(grant.accessGroupNames) : "an access group";
+ return `Granted via ${source}. Full ID: ${grant.id}`;
+};
diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx
index ca1e0413dcb..a993c1f6bd6 100644
--- a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx
+++ b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx
@@ -38,6 +38,9 @@ vi.mock("@/components/networking", () => ({
organizationInfoCall: vi.fn(),
getRouterSettingsCall: vi.fn().mockResolvedValue({ fields: [] }),
getPassThroughEndpointsCall: vi.fn(),
+ fetchMCPServers: vi.fn().mockResolvedValue([]),
+ fetchMCPToolsets: vi.fn().mockResolvedValue([]),
+ getAgentsList: vi.fn().mockResolvedValue({ agents: [] }),
}));
const can = vi.fn();
@@ -302,6 +305,47 @@ describe("TeamInfoView", () => {
);
});
+ it("shows MCP servers and agents inherited from access groups in the Object Permissions card, naming the group on hover", async () => {
+ const user = userEvent.setup();
+ vi.mocked(networking.fetchMCPServers).mockResolvedValue([
+ { server_id: "mcp-github-1234", server_name: "github", alias: "github" },
+ ]);
+ vi.mocked(networking.getAgentsList).mockResolvedValue({
+ agents: [{ agent_id: "agent-support-5678", agent_name: "support_agent" }],
+ });
+ const platformToolsGroup = {
+ access_group_id: "ag-1",
+ access_group_name: "platform-tools",
+ models: [],
+ mcp_server_ids: ["mcp-github-1234"],
+ agent_ids: ["agent-support-5678"],
+ };
+ const inheritedGrants = {
+ object_permission: null,
+ access_group_ids: ["ag-1"],
+ access_group_mcp_server_ids: ["mcp-github-1234"],
+ access_group_agent_ids: ["agent-support-5678"],
+ access_group_details: [platformToolsGroup],
+ };
+ vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData(inheritedGrants));
+
+ renderWithProviders();
+
+ const serverRow = await screen.findByText(/github \(mcp\.\.\.1234\)/);
+ const agentRow = await screen.findByText(/support_agent \(age\.\.\.5678\)/);
+ expect(screen.queryByText("No MCP servers, access groups, or toolsets configured")).not.toBeInTheDocument();
+ expect(screen.queryByText("No agents or access groups configured")).not.toBeInTheDocument();
+
+ await user.hover(serverRow);
+ expect(
+ await screen.findByText("Granted via access group platform-tools. Full ID: mcp-github-1234"),
+ ).toBeInTheDocument();
+ await user.hover(agentRow);
+ expect(
+ await screen.findByText("Granted via access group platform-tools. Full ID: agent-support-5678"),
+ ).toBeInTheDocument();
+ });
+
it("keeps the all-proxy-models badge non-clickable", async () => {
vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData({ models: ["all-proxy-models"] }));
@@ -1494,6 +1538,35 @@ describe("TeamInfoView", () => {
});
});
+ describe("team member settings", () => {
+ it("should populate Default Key Duration from the team's stored metadata", async () => {
+ const user = userEvent.setup({ delay: null });
+ vi.mocked(networking.teamInfoCall).mockResolvedValue(
+ createMockTeamData({ metadata: { team_member_key_duration: "30d" } }),
+ );
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.queryAllByText("Test Team").length).toBeGreaterThan(0);
+ });
+
+ await user.click(screen.getByRole("tab", { name: "Settings" }));
+
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: /edit settings/i })).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByRole("button", { name: /edit settings/i }));
+
+ await user.click(await screen.findByRole("button", { name: /team member settings/i }));
+
+ await waitFor(() => {
+ expect(screen.getByLabelText(/^Default Key Duration/)).toHaveValue("30d");
+ });
+ });
+ });
+
describe("guardrails dropdown grouping", () => {
const guardrail = (name: string, defaultOn: boolean) => ({
guardrail_name: name,
@@ -1844,6 +1917,8 @@ describe("TeamInfoView - the exact bytes the update call sends", () => {
mcp_access_groups: [],
mcp_tool_permissions: {},
mcp_toolsets: [],
+ agents: [],
+ agent_access_groups: [],
vector_stores: ["vs-1"],
};
@@ -1864,6 +1939,49 @@ describe("TeamInfoView - the exact bytes the update call sends", () => {
});
});
+ const openEditorWithAgents = async (user: ReturnType) => {
+ vi.mocked(networking.teamInfoCall).mockResolvedValue(
+ createMockTeamData({
+ models: ["gpt-4"],
+ object_permission: { agents: ["agent-1"], agent_access_groups: ["group-a"] },
+ }),
+ );
+ vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any);
+
+ renderWithProviders();
+ await waitFor(() => expect(screen.queryAllByText("Test Team").length).toBeGreaterThan(0));
+ await user.click(screen.getByRole("tab", { name: "Settings" }));
+ await user.click(await screen.findByRole("button", { name: /edit settings/i }));
+ await screen.findByLabelText("Team Name");
+ };
+
+ it("resends the stored agents and agent_access_groups when the selector is left untouched", async () => {
+ const user = userEvent.setup({ delay: null });
+ await openEditorWithAgents(user);
+
+ const payload = await save(user);
+
+ const objectPermission = wireBody(payload).object_permission as Record;
+ expect(objectPermission.agents).toStrictEqual(["agent-1"]);
+ expect(objectPermission.agent_access_groups).toStrictEqual(["group-a"]);
+ });
+
+ it("sends empty agents and agent_access_groups arrays after the last agent chip is removed", async () => {
+ const user = userEvent.setup({ delay: null });
+ await openEditorWithAgents(user);
+
+ await user.click(within(screen.getByLabelText("agent-1")).getByRole("button"));
+ await user.click(within(screen.getByLabelText("group:group-a")).getByRole("button"));
+ expect(screen.queryByLabelText("agent-1")).not.toBeInTheDocument();
+ expect(screen.queryByLabelText("group:group-a")).not.toBeInTheDocument();
+
+ const payload = await save(user);
+
+ const objectPermission = wireBody(payload).object_permission as Record;
+ expect(objectPermission.agents).toStrictEqual([]);
+ expect(objectPermission.agent_access_groups).toStrictEqual([]);
+ });
+
it("resends every stored value once both sections are opened", async () => {
const user = userEvent.setup({ delay: null });
await openEditor(user);
diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx
index 44f0d420fd6..23ecd19fdd7 100644
--- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx
+++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx
@@ -58,6 +58,7 @@ import {
TeamModelBadge,
TeamModelBadgeKind,
} from "./teamModelAccess";
+import { computeInheritedGrants } from "../permissions/inheritedGrants";
import MetadataKeyValueFields, {
metadataObjectToPairs,
metadataPairsSchema,
@@ -334,7 +335,7 @@ const toTeamFormValues = (info: TeamInfoRecord, effectiveGuardrails: string[]):
default_team_member_models: info.default_team_member_models || [],
team_member_budget: info.team_member_budget_table?.max_budget,
team_member_budget_duration: info.team_member_budget_table?.budget_duration,
- team_member_key_duration: info.team_member_key_duration,
+ team_member_key_duration: info.metadata?.team_member_key_duration,
team_member_tpm_limit: info.team_member_budget_table?.tpm_limit,
team_member_rpm_limit: info.team_member_budget_table?.rpm_limit,
budget_duration: info.budget_duration,
@@ -862,12 +863,8 @@ const TeamInfoView: React.FC = ({
agents: [],
accessGroups: [],
};
- if (agents && agents.length > 0) {
- updateData.object_permission.agents = agents;
- }
- if (agentAccessGroups && agentAccessGroups.length > 0) {
- updateData.object_permission.agent_access_groups = agentAccessGroups;
- }
+ updateData.object_permission.agents = agents;
+ updateData.object_permission.agent_access_groups = agentAccessGroups;
delete values.agents_and_groups;
// Handle vector stores permissions
@@ -936,6 +933,17 @@ const TeamInfoView: React.FC = ({
const { team_info: info } = teamData;
+ const inheritedMcpServers = computeInheritedGrants(
+ info.access_group_mcp_server_ids,
+ info.access_group_details,
+ (grant) => grant.mcp_server_ids,
+ );
+ const inheritedAgents = computeInheritedGrants(
+ info.access_group_agent_ids,
+ info.access_group_details,
+ (grant) => grant.agent_ids,
+ );
+
const initialKillSwitchOn = info.metadata?.disable_global_guardrails === true;
const allGuardrails: GuardrailListItem[] = guardrailsData?.guardrails ?? [];
@@ -1033,7 +1041,13 @@ const TeamInfoView: React.FC = ({
-
+
= ({
0 ? models : [NO_DEFAULT_MODELS];
}
-const describeGroups = (names: string[]): string =>
+export const describeGroups = (names: string[]): string =>
names.length > 1 ? `access groups ${names.join(", ")}` : `access group ${names[0]}`;
export function computeTeamModelBadges(
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 10b1983b54b..8b506329595 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
@@ -425,6 +425,22 @@ describe("KeyEditView", () => {
expect(screen.getByText("Policies")).toBeInTheDocument();
});
+ it("lists a prompt existing in several environments once in the dropdown", async () => {
+ vi.mocked(getPromptsList).mockResolvedValueOnce({
+ prompts: [
+ { prompt_id: "envgreet", litellm_params: {}, prompt_info: { prompt_type: "db" }, environment: "development" },
+ { prompt_id: "envgreet", litellm_params: {}, prompt_info: { prompt_type: "db" }, environment: "production" },
+ ],
+ });
+
+ renderAs("Admin");
+
+ const prompts = await screen.findByLabelText(/Prompts/);
+ await userEvent.type(prompts, "envgreet");
+
+ expect(await screen.findAllByRole("option", { name: "envgreet" })).toHaveLength(1);
+ });
+
it("should omit both fields and fire neither admin-only request for an internal user", async () => {
renderAs("Internal User");
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 3e772fd0e9b..9edfba4b981 100644
--- a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx
+++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx
@@ -165,7 +165,7 @@ export function KeyEditView({
if (!accessToken) return;
try {
const response = await getPromptsList(accessToken);
- setPromptsList(response.prompts.map((prompt) => prompt.prompt_id));
+ setPromptsList(Array.from(new Set(response.prompts.map((prompt) => prompt.prompt_id))));
} catch (error) {
console.error("Failed to fetch prompts:", error);
}
@@ -303,9 +303,9 @@ export function KeyEditView({
}
};
- const handleOrganizationChange = (setField: (value: string | null) => void, orgId: string | undefined) => {
- setField(orgId || null);
- setSelectedOrganizationId(orgId || null);
+ const handleOrganizationChange = (setField: (value: string | null) => void, orgId: string | null) => {
+ setField(orgId);
+ setSelectedOrganizationId(orgId);
form.setValue("team_id", undefined);
};
diff --git a/ui/litellm-dashboard/src/components/view_logs/LogsTableToolbar.tsx b/ui/litellm-dashboard/src/components/view_logs/LogsTableToolbar.tsx
index cb7836d603f..60657872ccc 100644
--- a/ui/litellm-dashboard/src/components/view_logs/LogsTableToolbar.tsx
+++ b/ui/litellm-dashboard/src/components/view_logs/LogsTableToolbar.tsx
@@ -92,7 +92,10 @@ export function LogsTableToolbar({
diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.test.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.test.tsx
index c368a43dd1e..93066c106ee 100644
--- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.test.tsx
+++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.test.tsx
@@ -139,19 +139,20 @@ describe("RequestLogsPanel", () => {
});
describe("server-grouped session pagination (#38060)", () => {
- it("requests session-grouped pages of 10 rows by default", async () => {
+ it("requests session-grouped pages of 10 rows by default without a cursor", async () => {
renderPanel();
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled());
expect(lastCall()?.params?.group_by_session).toBe(true);
+ expect(lastCall()?.params?.session_cursor).toBeUndefined();
expect(lastCall()?.page_size).toBe(10);
});
it("renders every row the server returns without client-side collapsing", async () => {
respondWith([
- logEntry({ request_id: "req-a", call_type: "acompletion", session_id: "sess-1", session_total_count: 3 }),
- logEntry({ request_id: "req-b", call_type: "acompletion", session_id: "sess-1", session_total_count: 3 }),
- logEntry({ request_id: "req-c", call_type: "acompletion", session_id: "sess-1", session_total_count: 3 }),
+ logEntry({ request_id: "req-a", session_id: "sess-1", session_total_count: 3 }),
+ logEntry({ request_id: "req-b", session_id: "sess-1", session_total_count: 3 }),
+ logEntry({ request_id: "req-c", session_id: "sess-1", session_total_count: 3 }),
]);
renderPanel();
@@ -177,6 +178,138 @@ describe("RequestLogsPanel", () => {
expect(within(row("req-llm") as HTMLElement).getByText("3")).toBeInTheDocument();
});
+ it("passes the server keyset cursor when navigating to the next page", async () => {
+ const firstPage = Array.from({ length: 50 }, (_, index) => logEntry({ request_id: `req-${index}` }));
+ vi.mocked(uiSpendLogsCall).mockResolvedValue({
+ data: firstPage,
+ total: 80,
+ page: 1,
+ page_size: 50,
+ total_pages: 2,
+ next_session_cursor: "2026-07-07 09:50:13|key-1|sess-1",
+ has_more: true,
+ });
+ renderPanel();
+
+ await waitFor(() => expect(row("req-0")).not.toBeNull());
+ fireEvent.click(screen.getByTestId("pagination-next"));
+
+ await waitFor(() => {
+ const call = lastCall();
+ expect(call?.params?.session_cursor).toBe("2026-07-07 09:50:13|key-1|sess-1");
+ expect(call?.page).toBe(2);
+ });
+ });
+
+ it("drops the cursor and returns to the first page when a filter changes", async () => {
+ const firstPage = Array.from({ length: 50 }, (_, index) => logEntry({ request_id: `req-${index}` }));
+ vi.mocked(uiSpendLogsCall).mockResolvedValue({
+ data: firstPage,
+ total: 80,
+ page: 1,
+ page_size: 50,
+ total_pages: 2,
+ next_session_cursor: "2026-07-07 09:50:13|key-1|sess-1",
+ has_more: true,
+ });
+ renderPanel();
+
+ await waitFor(() => expect(row("req-0")).not.toBeNull());
+ fireEvent.click(screen.getByTestId("pagination-next"));
+ await waitFor(() => expect(lastCall()?.page).toBe(2));
+
+ fireEvent.change(screen.getByTestId("datatable-search"), { target: { value: "req-elsewhere" } });
+
+ await waitFor(() => {
+ const call = lastCall();
+ expect(call?.page).toBe(1);
+ expect(call?.params?.session_cursor).toBeUndefined();
+ });
+ });
+
+ it("ignores another next click while the next page is still fetching", async () => {
+ const firstPage = Array.from({ length: 50 }, (_, index) => logEntry({ request_id: `req-${index}` }));
+ const firstResponse = {
+ data: firstPage,
+ total: 150,
+ page: 1,
+ page_size: 50,
+ total_pages: 3,
+ next_session_cursor: "2026-07-07 09:50:13|key-1|sess-1",
+ has_more: true,
+ };
+ vi.mocked(uiSpendLogsCall)
+ .mockResolvedValueOnce(firstResponse)
+ .mockImplementation(() => new Promise(() => {}));
+ renderPanel();
+
+ await waitFor(() => expect(row("req-0")).not.toBeNull());
+ fireEvent.click(screen.getByTestId("pagination-next"));
+ await waitFor(() => expect(lastCall()?.page).toBe(2));
+
+ fireEvent.click(screen.getByTestId("pagination-next"));
+
+ expect(lastCall()?.page).toBe(2);
+ expect(vi.mocked(uiSpendLogsCall).mock.calls.filter(([options]) => options.page === 3)).toHaveLength(0);
+ });
+
+ it("still moves to the next page while a live-tail refetch of the current page is in flight", async () => {
+ const firstPage = Array.from({ length: 50 }, (_, index) => logEntry({ request_id: `req-${index}` }));
+ const firstResponse = {
+ data: firstPage,
+ total: 150,
+ page: 1,
+ page_size: 50,
+ total_pages: 3,
+ next_session_cursor: "2026-07-07 09:50:13|key-1|sess-1",
+ has_more: true,
+ };
+ vi.mocked(uiSpendLogsCall)
+ .mockResolvedValueOnce(firstResponse)
+ .mockImplementation(() => new Promise(() => {}));
+ renderPanel();
+
+ await waitFor(() => expect(row("req-0")).not.toBeNull());
+ void testQueryClient.refetchQueries({ queryKey: ["logs", "table"] });
+ await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(2));
+
+ fireEvent.click(screen.getByTestId("pagination-next"));
+
+ await waitFor(() => {
+ const call = lastCall();
+ expect(call?.page).toBe(2);
+ expect(call?.params?.session_cursor).toBe("2026-07-07 09:50:13|key-1|sess-1");
+ });
+ });
+
+ it("drops the cursor and returns to the first page when Custom Range is toggled", async () => {
+ const user = userEvent.setup();
+ const firstPage = Array.from({ length: 50 }, (_, index) => logEntry({ request_id: `req-${index}` }));
+ vi.mocked(uiSpendLogsCall).mockResolvedValue({
+ data: firstPage,
+ total: 80,
+ page: 1,
+ page_size: 50,
+ total_pages: 2,
+ next_session_cursor: "2026-07-07 09:50:13|key-1|sess-1",
+ has_more: true,
+ });
+ renderPanel();
+
+ await waitFor(() => expect(row("req-0")).not.toBeNull());
+ fireEvent.click(screen.getByTestId("pagination-next"));
+ await waitFor(() => expect(lastCall()?.page).toBe(2));
+
+ await user.click(screen.getByRole("button", { name: /Last 24 Hours/i }));
+ await user.click(await screen.findByRole("button", { name: "Custom Range" }));
+
+ await waitFor(() => {
+ const call = lastCall();
+ expect(call?.page).toBe(1);
+ expect(call?.params?.session_cursor).toBeUndefined();
+ });
+ });
+
it("leaves single-call rows untouched", async () => {
respondWith([
logEntry({ request_id: "req-solo-a", session_id: "sess-a", session_total_count: 1 }),
@@ -395,9 +528,7 @@ describe("RequestLogsPanel", () => {
});
it("opens a deep-linked multi-call session log in session mode", async () => {
- respondWith([
- logEntry({ request_id: "req-llm", call_type: "acompletion", session_id: "sess-1", session_total_count: 3 }),
- ]);
+ respondWith([logEntry({ request_id: "req-llm", session_id: "sess-1", session_total_count: 3 })]);
renderPanel("?log_id=req-llm");
await waitFor(() => {
@@ -410,8 +541,8 @@ describe("RequestLogsPanel", () => {
it("clicking a multi-call session's row writes ?session_id= alongside ?log_id=", async () => {
const user = userEvent.setup();
respondWith([
- logEntry({ request_id: "req-llm", call_type: "acompletion", session_id: "sess-1", session_total_count: 3 }),
- logEntry({ request_id: "req-llm-2", call_type: "acompletion", session_id: "sess-1", session_total_count: 3 }),
+ logEntry({ request_id: "req-llm", session_id: "sess-1", session_total_count: 3 }),
+ logEntry({ request_id: "req-llm-2", session_id: "sess-1", session_total_count: 3 }),
]);
renderPanel();
@@ -426,7 +557,7 @@ describe("RequestLogsPanel", () => {
it("selecting another log while a session view is open keeps the session open", async () => {
const user = userEvent.setup();
respondWith([
- logEntry({ request_id: "req-llm", call_type: "acompletion", session_id: "sess-1", session_total_count: 3 }),
+ logEntry({ request_id: "req-llm", session_id: "sess-1", session_total_count: 3 }),
logEntry({ request_id: "req-unenriched" }),
]);
renderPanel("?log_id=req-llm");
diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx
index 2ebabf64ab0..96cf2bd5dde 100644
--- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx
+++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx
@@ -39,6 +39,7 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID,
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: PAGE_SIZE });
const [sorting, setSorting] = useState(DEFAULT_LOGS_SORTING);
const [columnFilters, setColumnFilters] = useState([]);
+ const [sessionCursors, setSessionCursors] = useState>({});
const [startTime, setStartTime] = useState(moment().subtract(24, "hours").format("YYYY-MM-DDTHH:mm"));
const [endTime, setEndTime] = useState(moment().format("YYYY-MM-DDTHH:mm"));
@@ -74,7 +75,7 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID,
sessionStorage.setItem("excludeInternalHealthChecks", JSON.stringify(excludeInternalHealthChecks));
}, [excludeInternalHealthChecks]);
- const { logsQuery, filteredLogs, allTeams } = useLogFilterLogic({
+ const { logsQuery, filteredLogs, allTeams, usesSessionCursor } = useLogFilterLogic({
accessToken,
token,
userRole,
@@ -88,6 +89,7 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID,
pagination,
isCustomDate,
sorting,
+ sessionCursors,
});
// Follow the table's own last fetch so a live-tail refresh carries the filter
@@ -163,23 +165,52 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID,
const others = previous.filter((filter) => filter.id !== LOG_FILTER_IDS.REQUEST_ID);
return value === "" ? others : [...others, { id: LOG_FILTER_IDS.REQUEST_ID, value }];
});
+ setSessionCursors({});
setPagination((previous) => ({ ...previous, pageIndex: 0 }));
}, []);
const handleSortingChange = useCallback>((updaterOrValue) => {
setSorting(updaterOrValue);
+ setSessionCursors({});
setPagination((previous) => ({ ...previous, pageIndex: 0 }));
}, []);
const handleColumnFiltersChange = useCallback>((updaterOrValue) => {
setColumnFilters(updaterOrValue);
+ setSessionCursors({});
setPagination((previous) => ({ ...previous, pageIndex: 0 }));
}, []);
const resetToFirstPage = useCallback(() => {
+ setSessionCursors({});
setPagination((previous) => ({ ...previous, pageIndex: 0 }));
}, []);
+ const handlePaginationChange = useCallback>(
+ (updaterOrValue) => {
+ const requested = typeof updaterOrValue === "function" ? updaterOrValue(pagination) : updaterOrValue;
+ if (!usesSessionCursor) {
+ setPagination(requested);
+ return;
+ }
+ if (requested.pageSize !== pagination.pageSize) {
+ setSessionCursors({});
+ setPagination({ ...requested, pageIndex: 0 });
+ return;
+ }
+ if (requested.pageIndex <= pagination.pageIndex) {
+ setPagination(requested);
+ return;
+ }
+ const nextCursor = filteredLogs.next_session_cursor;
+ if (!nextCursor || logsQuery.isPlaceholderData) return;
+ const nextPageIndex = pagination.pageIndex + 1;
+ setSessionCursors((previous) => ({ ...previous, [nextPageIndex]: nextCursor }));
+ setPagination({ ...requested, pageIndex: nextPageIndex });
+ },
+ [usesSessionCursor, pagination, filteredLogs.next_session_cursor, logsQuery.isPlaceholderData],
+ );
+
const handleExcludeInternalHealthChecksChange = useCallback(
(value: boolean) => {
setExcludeInternalHealthChecks(value);
@@ -256,7 +287,7 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID,
isLoading={logsQuery.isLoading}
isRefreshing={logsQuery.isFetching}
pagination={pagination}
- onPaginationChange={setPagination}
+ onPaginationChange={handlePaginationChange}
sorting={sorting}
onSortingChange={handleSortingChange}
columnFilters={columnFilters}
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 e3bacc0908a..3c9e6543c1c 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,41 @@ describe("Cost column", () => {
});
});
+describe("Tokens column", () => {
+ const sessionRow: Partial = {
+ request_id: "req-session-tokens",
+ total_tokens: 10,
+ prompt_tokens: 7,
+ completion_tokens: 3,
+ session_id: "sess-1",
+ session_total_count: 3,
+ };
+
+ it("shows the summed session token usage, not the representative call's tokens, for a multi-round session", () => {
+ const aggregatedRow: Partial = {
+ ...sessionRow,
+ session_total_tokens: 60,
+ session_total_prompt_tokens: 42,
+ session_total_completion_tokens: 18,
+ };
+ renderRows([logEntry(aggregatedRow)]);
+
+ const tokensCell = screen.getByRole("cell", { name: /\(42\+18\)/ });
+ expect(tokensCell).toHaveTextContent("60");
+ expect(tokensCell).toHaveTextContent("session total");
+ expect(screen.queryByText("10")).not.toBeInTheDocument();
+ expect(screen.queryByText("(7+3)")).not.toBeInTheDocument();
+ });
+
+ it("falls back to the call's own tokens with no session label when the backend sent no session token sums", () => {
+ renderRows([logEntry(sessionRow)]);
+
+ const tokensCell = screen.getByRole("cell", { name: /\(7\+3\)/ });
+ expect(tokensCell).toHaveTextContent("10");
+ expect(tokensCell).not.toHaveTextContent("session total");
+ });
+});
+
describe("Type column", () => {
it("shows the conversation badge and composition even when an MCP call represents the conversation", 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 8db0b106851..9d4dc4f7898 100644
--- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsTableColumns.tsx
+++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsTableColumns.tsx
@@ -263,13 +263,20 @@ export const getRequestLogsTableColumns = ({
meta: { numeric: true },
cell: ({ row }) => {
const log = row.original;
+ const showSessionTotal = (log.session_total_count || 1) > 1 && log.session_total_tokens != null;
+ const total = showSessionTotal ? log.session_total_tokens : log.total_tokens;
+ const prompt = showSessionTotal ? log.session_total_prompt_tokens : log.prompt_tokens;
+ const completion = showSessionTotal ? log.session_total_completion_tokens : log.completion_tokens;
return (
-
- {String(log.total_tokens || "0")}
-
- ({String(log.prompt_tokens || "0")}+{String(log.completion_tokens || "0")})
+
+
+ {String(total || "0")}
+
+ ({String(prompt || "0")}+{String(completion || "0")})
+
-
+ {showSessionTotal && session total}
+
);
},
},
diff --git a/ui/litellm-dashboard/src/components/view_logs/columns.tsx b/ui/litellm-dashboard/src/components/view_logs/columns.tsx
index 21e09faf454..b2e29c3a0c1 100644
--- a/ui/litellm-dashboard/src/components/view_logs/columns.tsx
+++ b/ui/litellm-dashboard/src/components/view_logs/columns.tsx
@@ -42,6 +42,9 @@ export type LogEntry = {
request_duration_ms?: number;
session_total_count?: number;
session_total_spend?: number;
+ session_total_tokens?: number;
+ session_total_prompt_tokens?: number;
+ session_total_completion_tokens?: number;
session_cache_hit_count?: number;
mcp_tool_call_count?: number;
mcp_tool_call_spend?: number;
diff --git a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx
index acd5be06593..90f0f0a60f1 100644
--- a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx
+++ b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx
@@ -15,6 +15,8 @@ export interface PaginatedResponse {
page_size: number;
total_pages: number;
total_is_capped?: boolean;
+ next_session_cursor?: string | null;
+ has_more?: boolean;
}
export const LOG_FILTER_IDS = {
@@ -109,6 +111,7 @@ export function useLogFilterLogic({
pagination,
isCustomDate,
sorting,
+ sessionCursors = {},
}: {
accessToken: string | null;
token: string | null;
@@ -123,11 +126,14 @@ export function useLogFilterLogic({
pagination: PaginationState;
isCustomDate: boolean;
sorting: SortingState;
+ sessionCursors?: Record;
}) {
const pageSize = pagination.pageSize || defaultPageSize;
const activeSort = sorting[0] ?? DEFAULT_LOGS_SORTING[0];
const sortBy: LogsSortField = isSortField(activeSort.id) ? activeSort.id : "startTime";
const sortOrder: "asc" | "desc" = activeSort.desc ? "desc" : "asc";
+ const usesSessionCursor = sortBy === "startTime";
+ const sessionCursor = usesSessionCursor ? sessionCursors[pagination.pageIndex] : undefined;
const logsQueryOptions: UseQueryOptions = {
queryKey: [
@@ -142,6 +148,7 @@ export function useLogFilterLogic({
sortBy,
sortOrder,
excludeInternalHealthChecks,
+ sessionCursor,
],
queryFn: async () => {
if (!accessToken || !token || !userRole || !userID) {
@@ -182,6 +189,7 @@ export function useLogFilterLogic({
sort_order: sortOrder,
exclude_internal_health_checks: excludeInternalHealthChecks,
group_by_session: true,
+ session_cursor: sessionCursor,
},
});
},
@@ -219,5 +227,6 @@ export function useLogFilterLogic({
logsQuery,
filteredLogs,
allTeams,
+ usesSessionCursor,
};
}
diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts
index 3a4e013b684..93bc1de8e7e 100644
--- a/ui/litellm-dashboard/src/lib/http/schema.d.ts
+++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts
@@ -16606,6 +16606,8 @@ export interface paths {
* Get list of users by sso_ids. Comma separated list of sso_ids.
* user_email: Optional[str]
* Filter users by partial email match
+ * search: Optional[str]
+ * Combined search: matches users whose user_id or user_email contains the value (case-insensitive)
* team: Optional[str]
* Filter users by team id. Will match if user has this team in their teams array.
* page: int
@@ -25779,9 +25781,9 @@ export interface components {
mcp_xff_num_trusted_hops?: number | null;
/**
* Missing Session Id
- * @description What to do with LLM API requests that carry no session id (x-litellm-session-id header, metadata.session_id, etc.). 'generate' stamps one id into litellm_session_id, litellm_trace_id and metadata.session_id so SpendLogs and logging callbacks agree; 'reject' returns 400. Unset keeps the legacy behavior where SpendLogs falls back to the trace id while callbacks get no session id.
+ * @description What to do with LLM API requests that carry no session id (x-litellm-session-id header, metadata.session_id, etc.). 'generate' stamps one id into litellm_session_id, litellm_trace_id and metadata.session_id so SpendLogs and logging callbacks agree; 'reject' returns 400; 'omit' leaves SpendLogs.session_id null, matching callbacks such as Langfuse that only record a client-established metadata.session_id. Unset keeps the legacy behavior where SpendLogs falls back to the trace id while callbacks get no session id.
*/
- missing_session_id?: ("generate" | "reject") | null;
+ missing_session_id?: ("generate" | "reject" | "omit") | null;
/**
* Model List Healthy Only
* @description When true, `/models`, `/v1/models/{id}` and `/model/info` hide models whose backing deployments are all unhealthy, for every caller, without needing `healthy_only=true` per request. Requires `background_health_checks: true`, and keeps deployment health state cached without turning on `enable_health_check_routing`, so routing is unaffected. With no health state nothing is hidden. Hiding is presentation-only, a hidden model can still be called.
@@ -44068,7 +44070,11 @@ export interface operations {
};
list_containers_containers_get: {
parameters: {
- query?: never;
+ query?: {
+ after?: string | null;
+ limit?: number | null;
+ order?: string | null;
+ };
header?: never;
path?: never;
cookie?: never;
@@ -44084,6 +44090,15 @@ export interface operations {
"application/json": unknown;
};
};
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
};
};
create_container_containers_post: {
@@ -56863,6 +56878,8 @@ export interface operations {
exclude_internal_health_checks?: boolean;
/** @description Paginate over sessions instead of raw logs: one representative row per session, total counts sessions */
group_by_session?: boolean;
+ /** @description Keyset cursor '||