Skip to content

Commit adfdcf1

Browse files
[Fix] UI - Hide Default Team Settings From Proxy Admin Viewers (#16900)
* Add fallback in sort to prevent NoneType and str comparison * Hide Default Team Settings from Proxy Admin Viewers --------- Co-authored-by: Krish Dholakia <[email protected]>
1 parent 013dcd8 commit adfdcf1

File tree

6 files changed

+287
-19
lines changed

6 files changed

+287
-19
lines changed

litellm/proxy/db/db_spend_update_writer.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -994,11 +994,11 @@ async def _update_daily_spend(
994994
# If _update_daily_spend ever gets the ability to write to multiple tables at once, the sorting
995995
# should sort by the table first.
996996
key=lambda x: (
997-
x[1]["date"],
997+
x[1].get("date") or "",
998998
x[1].get(entity_id_field) or "",
999-
x[1]["api_key"],
1000-
x[1]["model"],
1001-
x[1]["custom_llm_provider"],
999+
x[1].get("api_key") or "",
1000+
x[1].get("model") or "",
1001+
x[1].get("custom_llm_provider") or "",
10021002
),
10031003
)[:BATCH_SIZE]
10041004
)

tests/test_litellm/proxy/db/test_db_spend_update_writer.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,109 @@ async def test_update_daily_spend_sorting():
221221

222222
# Verify that table.upsert was called
223223
mock_table.upsert.assert_has_calls(upsert_calls)
224+
225+
226+
@pytest.mark.asyncio
227+
async def test_update_daily_spend_with_none_values_in_sorting_fields():
228+
"""
229+
Test that _update_daily_spend handles None values in sorting fields correctly.
230+
231+
This test ensures that when fields like date, api_key, model, or custom_llm_provider
232+
are None, the sorting doesn't crash with TypeError: '<' not supported between
233+
instances of 'NoneType' and 'str'.
234+
"""
235+
# Setup
236+
mock_prisma_client = MagicMock()
237+
mock_batcher = MagicMock()
238+
mock_table = MagicMock()
239+
mock_prisma_client.db.batch_.return_value.__aenter__.return_value = mock_batcher
240+
mock_batcher.litellm_dailyuserspend = mock_table
241+
242+
# Create transactions with None values in various sorting fields
243+
daily_spend_transactions = {
244+
"key1": {
245+
"user_id": "user1",
246+
"date": None, # None date
247+
"api_key": "test-api-key",
248+
"model": "gpt-4",
249+
"custom_llm_provider": "openai",
250+
"prompt_tokens": 10,
251+
"completion_tokens": 20,
252+
"spend": 0.1,
253+
"api_requests": 1,
254+
"successful_requests": 1,
255+
"failed_requests": 0,
256+
},
257+
"key2": {
258+
"user_id": "user2",
259+
"date": "2024-01-01",
260+
"api_key": None, # None api_key
261+
"model": "gpt-4",
262+
"custom_llm_provider": "openai",
263+
"prompt_tokens": 10,
264+
"completion_tokens": 20,
265+
"spend": 0.1,
266+
"api_requests": 1,
267+
"successful_requests": 1,
268+
"failed_requests": 0,
269+
},
270+
"key3": {
271+
"user_id": "user3",
272+
"date": "2024-01-01",
273+
"api_key": "test-api-key",
274+
"model": None, # None model
275+
"custom_llm_provider": "openai",
276+
"prompt_tokens": 10,
277+
"completion_tokens": 20,
278+
"spend": 0.1,
279+
"api_requests": 1,
280+
"successful_requests": 1,
281+
"failed_requests": 0,
282+
},
283+
"key4": {
284+
"user_id": "user4",
285+
"date": "2024-01-01",
286+
"api_key": "test-api-key",
287+
"model": "gpt-4",
288+
"custom_llm_provider": None, # None custom_llm_provider
289+
"prompt_tokens": 10,
290+
"completion_tokens": 20,
291+
"spend": 0.1,
292+
"api_requests": 1,
293+
"successful_requests": 1,
294+
"failed_requests": 0,
295+
},
296+
"key5": {
297+
"user_id": None, # None entity_id
298+
"date": "2024-01-01",
299+
"api_key": "test-api-key",
300+
"model": "gpt-4",
301+
"custom_llm_provider": "openai",
302+
"prompt_tokens": 10,
303+
"completion_tokens": 20,
304+
"spend": 0.1,
305+
"api_requests": 1,
306+
"successful_requests": 1,
307+
"failed_requests": 0,
308+
},
309+
}
310+
311+
# Call the method - this should not raise TypeError
312+
await DBSpendUpdateWriter._update_daily_spend(
313+
n_retry_times=1,
314+
prisma_client=mock_prisma_client,
315+
proxy_logging_obj=MagicMock(),
316+
daily_spend_transactions=daily_spend_transactions,
317+
entity_type="user",
318+
entity_id_field="user_id",
319+
table_name="litellm_dailyuserspend",
320+
unique_constraint_name="user_id_date_api_key_model_custom_llm_provider",
321+
)
322+
323+
# Verify that table.upsert was called (should be called 5 times, once for each transaction)
324+
assert mock_table.upsert.call_count == 5
325+
326+
224327
# Tag Spend Tracking Tests
225328

226329

ui/litellm-dashboard/src/components/OldTeams.test.tsx

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -466,3 +466,129 @@ describe("OldTeams - helper functions", () => {
466466
});
467467
});
468468
});
469+
470+
describe("OldTeams - Default Team Settings tab visibility", () => {
471+
beforeEach(() => {
472+
vi.clearAllMocks();
473+
});
474+
475+
it("should show Default Team Settings tab for Admin role", () => {
476+
const { getByRole } = render(
477+
<OldTeams
478+
teams={[
479+
{
480+
team_id: "1",
481+
team_alias: "Test Team",
482+
organization_id: "org-123",
483+
models: ["gpt-4"],
484+
max_budget: 100,
485+
budget_duration: "1d",
486+
tpm_limit: 1000,
487+
rpm_limit: 1000,
488+
created_at: new Date().toISOString(),
489+
keys: [],
490+
members_with_roles: [],
491+
},
492+
]}
493+
searchParams={{}}
494+
accessToken="test-token"
495+
setTeams={vi.fn()}
496+
userID="user-123"
497+
userRole="Admin"
498+
organizations={[]}
499+
/>,
500+
);
501+
502+
expect(getByRole("tab", { name: "Default Team Settings" })).toBeInTheDocument();
503+
});
504+
505+
it("should show Default Team Settings tab for proxy_admin role", () => {
506+
const { getByRole } = render(
507+
<OldTeams
508+
teams={[
509+
{
510+
team_id: "1",
511+
team_alias: "Test Team",
512+
organization_id: "org-123",
513+
models: ["gpt-4"],
514+
max_budget: 100,
515+
budget_duration: "1d",
516+
tpm_limit: 1000,
517+
rpm_limit: 1000,
518+
created_at: new Date().toISOString(),
519+
keys: [],
520+
members_with_roles: [],
521+
},
522+
]}
523+
searchParams={{}}
524+
accessToken="test-token"
525+
setTeams={vi.fn()}
526+
userID="user-123"
527+
userRole="proxy_admin"
528+
organizations={[]}
529+
/>,
530+
);
531+
532+
expect(getByRole("tab", { name: "Default Team Settings" })).toBeInTheDocument();
533+
});
534+
535+
it("should not show Default Team Settings tab for proxy_admin_viewer role", () => {
536+
const { queryByRole } = render(
537+
<OldTeams
538+
teams={[
539+
{
540+
team_id: "1",
541+
team_alias: "Test Team",
542+
organization_id: "org-123",
543+
models: ["gpt-4"],
544+
max_budget: 100,
545+
budget_duration: "1d",
546+
tpm_limit: 1000,
547+
rpm_limit: 1000,
548+
created_at: new Date().toISOString(),
549+
keys: [],
550+
members_with_roles: [],
551+
},
552+
]}
553+
searchParams={{}}
554+
accessToken="test-token"
555+
setTeams={vi.fn()}
556+
userID="user-123"
557+
userRole="proxy_admin_viewer"
558+
organizations={[]}
559+
/>,
560+
);
561+
562+
expect(queryByRole("tab", { name: "Default Team Settings" })).not.toBeInTheDocument();
563+
});
564+
565+
it("should not show Default Team Settings tab for Admin Viewer role", () => {
566+
const { queryByRole } = render(
567+
<OldTeams
568+
teams={[
569+
{
570+
team_id: "1",
571+
team_alias: "Test Team",
572+
organization_id: "org-123",
573+
models: ["gpt-4"],
574+
max_budget: 100,
575+
budget_duration: "1d",
576+
tpm_limit: 1000,
577+
rpm_limit: 1000,
578+
created_at: new Date().toISOString(),
579+
keys: [],
580+
members_with_roles: [],
581+
},
582+
]}
583+
searchParams={{}}
584+
accessToken="test-token"
585+
setTeams={vi.fn()}
586+
userID="user-123"
587+
userRole="Admin Viewer"
588+
organizations={[]}
589+
/>,
590+
);
591+
592+
expect(queryByRole("tab", { name: "Default Team Settings" })).not.toBeInTheDocument();
593+
});
594+
});

ui/litellm-dashboard/src/components/OldTeams.tsx

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import AvailableTeamsPanel from "@/components/team/available_teams";
22
import TeamInfoView from "@/components/team/team_info";
33
import TeamSSOSettings from "@/components/TeamSSOSettings";
4-
import { updateExistingKeys } from "@/utils/dataUtils";
5-
import { isAdminRole } from "@/utils/roles";
4+
import { isProxyAdminRole } from "@/utils/roles";
65
import { InfoCircleOutlined } from "@ant-design/icons";
76
import { ChevronDownIcon, ChevronRightIcon, PencilAltIcon, RefreshIcon, TrashIcon } from "@heroicons/react/outline";
87
import {
@@ -31,10 +30,10 @@ import {
3130
Text,
3231
TextInput,
3332
} from "@tremor/react";
34-
import { Button as Button2, Form, Input, Modal, Select as Select2, Switch, Tooltip, Typography } from "antd";
33+
import { Button as Button2, Form, Input, Modal, Select as Select2, Tooltip, Typography } from "antd";
34+
import { AlertTriangleIcon, XIcon } from "lucide-react";
3535
import React, { useEffect, useState } from "react";
3636
import { formatNumberWithCommas } from "../utils/dataUtils";
37-
import DeleteResourceModal from "./common_components/DeleteResourceModal";
3837
import { fetchTeams } from "./common_components/fetch_teams";
3938
import ModelAliasManager from "./common_components/ModelAliasManager";
4039
import PremiumLoggingSettings from "./common_components/PremiumLoggingSettings";
@@ -47,15 +46,7 @@ import type { KeyResponse, Team } from "./key_team_helpers/key_list";
4746
import MCPServerSelector from "./mcp_server_management/MCPServerSelector";
4847
import MCPToolPermissions from "./mcp_server_management/MCPToolPermissions";
4948
import NotificationsManager from "./molecules/notifications_manager";
50-
import {
51-
Member,
52-
Organization,
53-
fetchMCPAccessGroups,
54-
getGuardrailsList,
55-
teamCreateCall,
56-
teamDeleteCall,
57-
v2TeamListCall,
58-
} from "./networking";
49+
import { Organization, fetchMCPAccessGroups, getGuardrailsList, teamDeleteCall } from "./networking";
5950
import NumericalInput from "./shared/numerical_input";
6051
import VectorStoreSelector from "./vector_store_management/VectorStoreSelector";
6152

@@ -85,6 +76,9 @@ interface EditTeamModalProps {
8576
onSubmit: (data: FormData) => void; // Assuming FormData is the type of data to be submitted
8677
}
8778

79+
import { updateExistingKeys } from "@/utils/dataUtils";
80+
import { Member, teamCreateCall, v2TeamListCall } from "./networking";
81+
8882
interface TeamInfo {
8983
members_with_roles: Member[];
9084
}
@@ -615,7 +609,7 @@ const Teams: React.FC<TeamProps> = ({
615609
<div className="flex">
616610
<Tab>Your Teams</Tab>
617611
<Tab>Available Teams</Tab>
618-
{isAdminRole(userRole || "") && <Tab>Default Team Settings</Tab>}
612+
{isProxyAdminRole(userRole || "") && <Tab>Default Team Settings</Tab>}
619613
</div>
620614
<div className="flex items-center space-x-2">
621615
{lastRefreshed && <Text>Last Refreshed: {lastRefreshed}</Text>}
@@ -1004,7 +998,7 @@ const Teams: React.FC<TeamProps> = ({
1004998
<TabPanel>
1005999
<AvailableTeamsPanel accessToken={accessToken} userID={userID} />
10061000
</TabPanel>
1007-
{isAdminRole(userRole || "") && (
1001+
{isProxyAdminRole(userRole || "") && (
10081002
<TabPanel>
10091003
<TeamSSOSettings accessToken={accessToken} userID={userID || ""} userRole={userRole || ""} />
10101004
</TabPanel>
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { describe, it, expect } from "vitest";
2+
import { isAdminRole, isProxyAdminRole } from "./roles";
3+
4+
describe("roles", () => {
5+
describe("isAdminRole", () => {
6+
it("should return true for all admin roles", () => {
7+
expect(isAdminRole("Admin")).toBe(true);
8+
expect(isAdminRole("Admin Viewer")).toBe(true);
9+
expect(isAdminRole("proxy_admin")).toBe(true);
10+
expect(isAdminRole("proxy_admin_viewer")).toBe(true);
11+
expect(isAdminRole("org_admin")).toBe(true);
12+
});
13+
14+
it("should return false for non-admin roles", () => {
15+
expect(isAdminRole("Internal User")).toBe(false);
16+
expect(isAdminRole("Internal Viewer")).toBe(false);
17+
expect(isAdminRole("regular_user")).toBe(false);
18+
expect(isAdminRole("")).toBe(false);
19+
});
20+
});
21+
22+
describe("isProxyAdminRole", () => {
23+
it("should return true for proxy_admin and Admin roles", () => {
24+
expect(isProxyAdminRole("proxy_admin")).toBe(true);
25+
expect(isProxyAdminRole("Admin")).toBe(true);
26+
});
27+
28+
it("should return false for other admin roles", () => {
29+
expect(isProxyAdminRole("Admin Viewer")).toBe(false);
30+
expect(isProxyAdminRole("proxy_admin_viewer")).toBe(false);
31+
expect(isProxyAdminRole("org_admin")).toBe(false);
32+
});
33+
34+
it("should return false for non-admin roles", () => {
35+
expect(isProxyAdminRole("Internal User")).toBe(false);
36+
expect(isProxyAdminRole("Internal Viewer")).toBe(false);
37+
expect(isProxyAdminRole("regular_user")).toBe(false);
38+
expect(isProxyAdminRole("")).toBe(false);
39+
});
40+
});
41+
});

ui/litellm-dashboard/src/utils/roles.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,7 @@ export const rolesWithWriteAccess = ["Internal User", "Admin", "proxy_admin"];
1111
export const isAdminRole = (role: string): boolean => {
1212
return all_admin_roles.includes(role);
1313
};
14+
15+
export const isProxyAdminRole = (role: string): boolean => {
16+
return role === "proxy_admin" || role === "Admin";
17+
};

0 commit comments

Comments
 (0)