Command:
diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/utils.test.ts b/ui/litellm-dashboard/src/components/EntityUsageExport/utils.test.ts
index 856b2726c6d..802555b9d0b 100644
--- a/ui/litellm-dashboard/src/components/EntityUsageExport/utils.test.ts
+++ b/ui/litellm-dashboard/src/components/EntityUsageExport/utils.test.ts
@@ -1036,6 +1036,18 @@ describe("EntityUsageExport utils", () => {
failed_requests: 2,
total_tokens: 500,
},
+ api_key_breakdown: {
+ key1: {
+ metrics: {
+ spend: 5.0,
+ api_requests: 50,
+ successful_requests: 48,
+ failed_requests: 2,
+ total_tokens: 500,
+ },
+ metadata: { team_id: "team-1" },
+ },
+ },
},
"gpt-3.5-turbo": {
metrics: {
@@ -1045,6 +1057,18 @@ describe("EntityUsageExport utils", () => {
failed_requests: 3,
total_tokens: 500,
},
+ api_key_breakdown: {
+ key2: {
+ metrics: {
+ spend: 5.5,
+ api_requests: 50,
+ successful_requests: 47,
+ failed_requests: 3,
+ total_tokens: 500,
+ },
+ metadata: { team_id: "team-1" },
+ },
+ },
},
},
},
@@ -1118,6 +1142,18 @@ describe("EntityUsageExport utils", () => {
failed_requests: 5,
total_tokens: 1000,
},
+ api_key_breakdown: {
+ key1: {
+ metrics: {
+ spend: 10.5,
+ api_requests: 100,
+ successful_requests: 95,
+ failed_requests: 5,
+ total_tokens: 1000,
+ },
+ metadata: { team_id: "team-1" },
+ },
+ },
},
},
},
@@ -1134,12 +1170,229 @@ describe("EntityUsageExport utils", () => {
);
});
- it("should aggregate model metrics from api key breakdown", () => {
+ it("should attribute each model only its own per-key spend", () => {
const result = generateDailyWithModelsData(mockSpendDataWithModels, "Team");
const gpt4Entry = result.find((r) => r.Model === "gpt-4");
- expect(gpt4Entry).toBeDefined();
- expect(gpt4Entry?.Requests).toBeGreaterThan(0);
+ const gpt35Entry = result.find((r) => r.Model === "gpt-3.5-turbo");
+
+ expect(gpt4Entry?.["Spend ($)"]).toBe("5.0000");
+ expect(gpt4Entry?.Requests).toBe(50);
+ expect(gpt4Entry?.["Total Tokens"]).toBe(500);
+
+ expect(gpt35Entry?.["Spend ($)"]).toBe("5.5000");
+ expect(gpt35Entry?.Requests).toBe(50);
+ expect(gpt35Entry?.["Total Tokens"]).toBe(500);
+ });
+
+ it("should not duplicate a user's spend across every model (regression for LIT overcount)", () => {
+ // One user, one key, that key used two models. The entity-level api_key_breakdown
+ // carries the key's total (8.0) across both models; each model's api_key_breakdown
+ // carries only that model's share (3.0 + 5.0). The per-model rows must sum back to
+ // the user-day total, not repeat the total once per model.
+ const data: EntitySpendData = {
+ results: [
+ {
+ date: "2025-02-14",
+ breakdown: {
+ entities: {
+ user1: {
+ metrics: {
+ spend: 8.0,
+ api_requests: 80,
+ successful_requests: 78,
+ failed_requests: 2,
+ total_tokens: 800,
+ prompt_tokens: 500,
+ completion_tokens: 300,
+ cache_read_input_tokens: 0,
+ cache_creation_input_tokens: 0,
+ },
+ api_key_breakdown: {
+ key1: {
+ metrics: {
+ spend: 8.0,
+ api_requests: 80,
+ successful_requests: 78,
+ failed_requests: 2,
+ total_tokens: 800,
+ },
+ metadata: { team_id: "team-1" },
+ },
+ },
+ },
+ },
+ models: {
+ "claude-3-haiku": {
+ metrics: {
+ spend: 3.0,
+ api_requests: 30,
+ successful_requests: 29,
+ failed_requests: 1,
+ total_tokens: 300,
+ },
+ api_key_breakdown: {
+ key1: {
+ metrics: {
+ spend: 3.0,
+ api_requests: 30,
+ successful_requests: 29,
+ failed_requests: 1,
+ total_tokens: 300,
+ },
+ metadata: { team_id: "team-1" },
+ },
+ },
+ },
+ "claude-sonnet-4-5": {
+ metrics: {
+ spend: 5.0,
+ api_requests: 50,
+ successful_requests: 49,
+ failed_requests: 1,
+ total_tokens: 500,
+ },
+ api_key_breakdown: {
+ key1: {
+ metrics: {
+ spend: 5.0,
+ api_requests: 50,
+ successful_requests: 49,
+ failed_requests: 1,
+ total_tokens: 500,
+ },
+ metadata: { team_id: "team-1" },
+ },
+ },
+ },
+ },
+ },
+ },
+ ],
+ metadata: {
+ total_spend: 8.0,
+ total_api_requests: 80,
+ total_successful_requests: 78,
+ total_failed_requests: 2,
+ total_tokens: 800,
+ },
+ };
+
+ const result = generateDailyWithModelsData(data, "User");
+
+ expect(result).toHaveLength(2);
+
+ const haiku = result.find((r) => r.Model === "claude-3-haiku");
+ const sonnet = result.find((r) => r.Model === "claude-sonnet-4-5");
+
+ expect(haiku?.["Spend ($)"]).toBe("3.0000");
+ expect(sonnet?.["Spend ($)"]).toBe("5.0000");
+
+ const totalSpend = result.reduce((sum, r) => sum + parseFloat(r["Spend ($)"].replace(/,/g, "")), 0);
+ const totalRequests = result.reduce((sum, r) => sum + r.Requests, 0);
+ const totalTokens = result.reduce((sum, r) => sum + r["Total Tokens"], 0);
+
+ expect(totalSpend).toBeCloseTo(8.0, 4);
+ expect(totalRequests).toBe(80);
+ expect(totalTokens).toBe(800);
+ });
+
+ it("should omit models the user never called instead of fanning out", () => {
+ // A second key (key2) belongs to a different user and is the only caller of
+ // gpt-3.5-turbo. user1 only used key1 -> gpt-4. user1 must get exactly one row.
+ const data: EntitySpendData = {
+ results: [
+ {
+ date: "2025-02-14",
+ breakdown: {
+ entities: {
+ user1: {
+ metrics: {
+ spend: 5.0,
+ api_requests: 50,
+ successful_requests: 48,
+ failed_requests: 2,
+ total_tokens: 500,
+ prompt_tokens: 300,
+ completion_tokens: 200,
+ cache_read_input_tokens: 0,
+ cache_creation_input_tokens: 0,
+ },
+ api_key_breakdown: {
+ key1: {
+ metrics: {
+ spend: 5.0,
+ api_requests: 50,
+ successful_requests: 48,
+ failed_requests: 2,
+ total_tokens: 500,
+ },
+ metadata: { team_id: "team-1" },
+ },
+ },
+ },
+ },
+ models: {
+ "gpt-4": {
+ metrics: {
+ spend: 5.0,
+ api_requests: 50,
+ successful_requests: 48,
+ failed_requests: 2,
+ total_tokens: 500,
+ },
+ api_key_breakdown: {
+ key1: {
+ metrics: {
+ spend: 5.0,
+ api_requests: 50,
+ successful_requests: 48,
+ failed_requests: 2,
+ total_tokens: 500,
+ },
+ metadata: { team_id: "team-1" },
+ },
+ },
+ },
+ "gpt-3.5-turbo": {
+ metrics: {
+ spend: 9.0,
+ api_requests: 90,
+ successful_requests: 90,
+ failed_requests: 0,
+ total_tokens: 900,
+ },
+ api_key_breakdown: {
+ key2: {
+ metrics: {
+ spend: 9.0,
+ api_requests: 90,
+ successful_requests: 90,
+ failed_requests: 0,
+ total_tokens: 900,
+ },
+ metadata: { team_id: "team-2" },
+ },
+ },
+ },
+ },
+ },
+ },
+ ],
+ metadata: {
+ total_spend: 14.0,
+ total_api_requests: 140,
+ total_successful_requests: 138,
+ total_failed_requests: 2,
+ total_tokens: 1400,
+ },
+ };
+
+ const result = generateDailyWithModelsData(data, "User");
+
+ expect(result).toHaveLength(1);
+ expect(result[0].Model).toBe("gpt-4");
+ expect(result[0]["Spend ($)"]).toBe("5.0000");
});
it("should use team alias when available", () => {
@@ -1312,6 +1565,18 @@ describe("EntityUsageExport utils", () => {
failed_requests: 5,
total_tokens: 1000,
},
+ api_key_breakdown: {
+ key1: {
+ metrics: {
+ spend: 10.5,
+ api_requests: 100,
+ successful_requests: 95,
+ failed_requests: 5,
+ total_tokens: 1000,
+ },
+ metadata: { team_id: "team-1" },
+ },
+ },
},
},
},
@@ -1595,7 +1860,43 @@ describe("EntityUsageExport utils", () => {
metadata: { team_id: "team-1", key_alias: "staging-key" },
},
},
- models: { "gpt-4": { metrics: { spend: 35, api_requests: 350, total_tokens: 3500 } } },
+ models: {
+ "gpt-4": {
+ metrics: { spend: 35.8, api_requests: 350, total_tokens: 3500 },
+ api_key_breakdown: {
+ key1: {
+ metrics: {
+ spend: 10.5,
+ api_requests: 100,
+ successful_requests: 95,
+ failed_requests: 5,
+ total_tokens: 1000,
+ },
+ metadata: { team_id: "team-1" },
+ },
+ key1b: {
+ metrics: {
+ spend: 5,
+ api_requests: 50,
+ successful_requests: 48,
+ failed_requests: 2,
+ total_tokens: 500,
+ },
+ metadata: { team_id: "team-1" },
+ },
+ key2: {
+ metrics: {
+ spend: 20.3,
+ api_requests: 200,
+ successful_requests: 195,
+ failed_requests: 5,
+ total_tokens: 2000,
+ },
+ metadata: { team_id: "team-2" },
+ },
+ },
+ },
+ },
},
})),
};
@@ -1699,6 +2000,13 @@ describe("EntityUsageExport utils", () => {
const result = generateDailyWithModelsData(aggregatedSpendData, "Team");
expect(result.length).toBeGreaterThan(0);
expect(result[0]).toHaveProperty("Model");
+
+ // team-1 = key1 (10.5) + key1b (5) on gpt-4; team-2 = key2 (20.3) on gpt-4.
+ // Spend must aggregate per team-key, not repeat the model total per team.
+ const team1 = result.find((r) => r["Team ID"] === "team-1");
+ const team2 = result.find((r) => r["Team ID"] === "team-2");
+ expect(team1?.["Spend ($)"]).toBe("15.5000");
+ expect(team2?.["Spend ($)"]).toBe("20.3000");
});
});
});
diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts b/ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts
index 2cab3143b9e..cec8af2dc74 100644
--- a/ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts
+++ b/ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts
@@ -237,9 +237,13 @@ export const generateDailyWithModelsData = (
}
Object.entries(day.breakdown.models || {}).forEach(([model, modelData]: [string, any]) => {
- const apiKeyBreakdown = entityData.api_key_breakdown || {};
+ const entityApiKeys = entityData.api_key_breakdown || {};
+ const modelApiKeys = modelData.api_key_breakdown || {};
+
+ Object.keys(entityApiKeys).forEach((apiKey) => {
+ const keyMetrics = modelApiKeys[apiKey]?.metrics;
+ if (!keyMetrics) return;
- Object.entries(apiKeyBreakdown).forEach(([apiKey, apiKeyData]: [string, any]) => {
if (!dailyEntityModels[entity][model]) {
dailyEntityModels[entity][model] = {
spend: 0,
@@ -249,11 +253,11 @@ export const generateDailyWithModelsData = (
tokens: 0,
};
}
- dailyEntityModels[entity][model].spend += apiKeyData.metrics.spend || 0;
- dailyEntityModels[entity][model].requests += apiKeyData.metrics.api_requests || 0;
- dailyEntityModels[entity][model].successful += apiKeyData.metrics.successful_requests || 0;
- dailyEntityModels[entity][model].failed += apiKeyData.metrics.failed_requests || 0;
- dailyEntityModels[entity][model].tokens += apiKeyData.metrics.total_tokens || 0;
+ dailyEntityModels[entity][model].spend += keyMetrics.spend || 0;
+ dailyEntityModels[entity][model].requests += keyMetrics.api_requests || 0;
+ dailyEntityModels[entity][model].successful += keyMetrics.successful_requests || 0;
+ dailyEntityModels[entity][model].failed += keyMetrics.failed_requests || 0;
+ dailyEntityModels[entity][model].tokens += keyMetrics.total_tokens || 0;
});
});
});
diff --git a/ui/litellm-dashboard/src/components/OldTeams.test.tsx b/ui/litellm-dashboard/src/components/OldTeams.test.tsx
index d777ba1b0dc..0b6e5786aaf 100644
--- a/ui/litellm-dashboard/src/components/OldTeams.test.tsx
+++ b/ui/litellm-dashboard/src/components/OldTeams.test.tsx
@@ -1097,3 +1097,52 @@ describe("OldTeams - delete team warning copy", () => {
);
});
});
+
+describe("OldTeams - LIT-2530 organization stays optional for proxy admin with a single org", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockTeamInfoView.mockClear();
+ vi.mocked(fetchAvailableModelsForTeamOrKey).mockResolvedValue(["gpt-4"]);
+ vi.mocked(fetchMCPAccessGroups).mockResolvedValue([]);
+ vi.mocked(getGuardrailsList).mockResolvedValue({ guardrails: [] });
+ vi.mocked(teamListCall).mockResolvedValue({ teams: [], total: 0, page: 1, page_size: 100, total_pages: 1 });
+ vi.mocked(teamCreateCall).mockResolvedValue({
+ team_id: "new-team-1",
+ team_alias: "No Org Team",
+ models: ["gpt-4"],
+ organization_id: null,
+ keys: [],
+ members_with_roles: [],
+ spend: 0,
+ });
+ mockUseOrganizations.mockReturnValue({
+ data: [{ organization_id: "org-1", organization_alias: "Org 1", models: [], members: [] }],
+ });
+ });
+
+ it("creates a team with no organization when exactly one organization exists", async () => {
+ renderWithQueryClient(
);
+
+ const createButton = screen.getAllByRole("button", { name: /create team/i })[0];
+ act(() => {
+ fireEvent.click(createButton);
+ });
+
+ await waitFor(() => {
+ expect(screen.getByLabelText(/team name/i)).toBeInTheDocument();
+ });
+
+ fireEvent.change(screen.getByLabelText(/team name/i), { target: { value: "No Org Team" } });
+ fireEvent.change(screen.getByTestId("create-team-models-select"), { target: { value: "gpt-4" } });
+
+ const submitButtons = screen.getAllByRole("button", { name: /create team/i });
+ fireEvent.click(submitButtons[submitButtons.length - 1]);
+
+ await waitFor(() => {
+ expect(teamCreateCall).toHaveBeenCalledWith(
+ "test-token",
+ expect.objectContaining({ team_alias: "No Org Team", organization_id: null }),
+ );
+ });
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/OldTeams.tsx b/ui/litellm-dashboard/src/components/OldTeams.tsx
index adfec4bdf6a..be9015e3730 100644
--- a/ui/litellm-dashboard/src/components/OldTeams.tsx
+++ b/ui/litellm-dashboard/src/components/OldTeams.tsx
@@ -262,14 +262,15 @@ const Teams: React.FC
= ({ accessToken, userID, userRole, premiumUser
useEffect(() => {
if (isTeamModalVisible) {
const adminOrgs = getAdminOrganizations(userRole, userID, organizations);
+ const isOrgAdmin = userRole !== "Admin";
- // If there's exactly one organization the user is admin for, preselect it
- if (adminOrgs.length === 1) {
+ // Org admins must scope a team to an org, so with exactly one we preselect it.
+ // Proxy admins can create org-less teams, so the field stays optional regardless of org count.
+ if (isOrgAdmin && adminOrgs.length === 1) {
const org = adminOrgs[0];
form.setFieldValue("organization_id", org.organization_id);
setCurrentOrgForCreateTeam(org);
} else {
- // Reset the organization selection for multiple orgs
form.setFieldValue("organization_id", currentOrg?.organization_id || null);
setCurrentOrgForCreateTeam(currentOrg);
}
@@ -1132,7 +1133,7 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser
: []
}
help={
- isSingleOrg
+ isOrgAdmin && isSingleOrg
? "You can only create teams within this organization"
: isOrgAdmin
? "required"
@@ -1142,7 +1143,7 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser