mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-06 15:10:39 +00:00
fix(ui): CSV export empty on Global Usage page
Aggregated endpoint returns empty breakdown.entities; fall back to grouping breakdown.api_keys by team_id.
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
||||
getEntityBreakdown,
|
||||
handleExportCSV,
|
||||
handleExportJSON,
|
||||
resolveEntities,
|
||||
} from "./utils";
|
||||
|
||||
vi.mock("@/utils/dataUtils", () => ({
|
||||
@@ -1561,4 +1562,137 @@ describe("EntityUsageExport utils", () => {
|
||||
window.Blob = originalBlob;
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveEntities and aggregated endpoint fallback", () => {
|
||||
// Simulates the response from /user/daily/activity/aggregated which has
|
||||
// empty entities but populated api_keys at the breakdown level.
|
||||
// Derived from mockSpendData: flatten all entities' api_key_breakdowns
|
||||
// into top-level api_keys, clear entities, and add a second key for team-1
|
||||
// to test multi-key grouping.
|
||||
const aggregatedSpendData: EntitySpendData = {
|
||||
...mockSpendData,
|
||||
results: mockSpendData.results.slice(0, 1).map((day) => ({
|
||||
...day,
|
||||
breakdown: {
|
||||
entities: {},
|
||||
api_keys: {
|
||||
...Object.fromEntries(
|
||||
Object.values(day.breakdown.entities as Record<string, any>).flatMap((e: any) =>
|
||||
Object.entries(e.api_key_breakdown || {}),
|
||||
),
|
||||
),
|
||||
// Extra key on team-1 to test multi-key-per-team aggregation
|
||||
key1b: {
|
||||
metrics: { spend: 5, api_requests: 50, successful_requests: 48, failed_requests: 2, total_tokens: 500 },
|
||||
metadata: { team_id: "team-1", key_alias: "staging-key" },
|
||||
},
|
||||
},
|
||||
models: { "gpt-4": { metrics: { spend: 35, api_requests: 350, total_tokens: 3500 } } },
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
describe("resolveEntities", () => {
|
||||
it("should return entities when populated", () => {
|
||||
const breakdown = {
|
||||
entities: { e1: { metrics: { spend: 1 } } },
|
||||
api_keys: { k1: { metrics: { spend: 2 }, metadata: { team_id: "t1" } } },
|
||||
};
|
||||
const result = resolveEntities(breakdown);
|
||||
expect(result).toBe(breakdown.entities);
|
||||
});
|
||||
|
||||
it("should aggregate api_keys into entities when entities is empty", () => {
|
||||
const breakdown = aggregatedSpendData.results[0].breakdown;
|
||||
const result = resolveEntities(breakdown);
|
||||
|
||||
// Two teams: team-1 (key1+key2) and team-2 (key3)
|
||||
expect(Object.keys(result)).toHaveLength(2);
|
||||
expect(result["team-1"]).toBeDefined();
|
||||
expect(result["team-2"]).toBeDefined();
|
||||
|
||||
// team-1 spend = 10.5 (key1) + 5 (key1b)
|
||||
expect(result["team-1"].metrics.spend).toBe(15.5);
|
||||
expect(result["team-1"].metrics.api_requests).toBe(150);
|
||||
expect(result["team-1"].metrics.total_tokens).toBe(1500);
|
||||
|
||||
// team-2 spend = 20.3 (key2)
|
||||
expect(result["team-2"].metrics.spend).toBe(20.3);
|
||||
expect(result["team-2"].metrics.api_requests).toBe(200);
|
||||
});
|
||||
|
||||
it("should use 'Unassigned' for keys without team_id", () => {
|
||||
const breakdown = {
|
||||
entities: {},
|
||||
api_keys: {
|
||||
k1: {
|
||||
metrics: { spend: 7, api_requests: 10, successful_requests: 10, failed_requests: 0, total_tokens: 100 },
|
||||
metadata: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = resolveEntities(breakdown);
|
||||
expect(result["Unassigned"]).toBeDefined();
|
||||
expect(result["Unassigned"].metrics.spend).toBe(7);
|
||||
});
|
||||
|
||||
it("should handle missing or empty api_keys gracefully", () => {
|
||||
expect(Object.keys(resolveEntities({ entities: {}, api_keys: {} }))).toHaveLength(0);
|
||||
expect(Object.keys(resolveEntities({ entities: {} }))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should preserve api_key_breakdown on aggregated entities", () => {
|
||||
const breakdown = aggregatedSpendData.results[0].breakdown;
|
||||
const result = resolveEntities(breakdown);
|
||||
|
||||
// team-1 should have key1 and key1b in api_key_breakdown
|
||||
expect(Object.keys(result["team-1"].api_key_breakdown)).toEqual(["key1", "key1b"]);
|
||||
// team-2 should have key2
|
||||
expect(Object.keys(result["team-2"].api_key_breakdown)).toEqual(["key2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEntityBreakdown with aggregated data", () => {
|
||||
it("should produce breakdown from api_keys when entities is empty", () => {
|
||||
const result = getEntityBreakdown(aggregatedSpendData);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
|
||||
// Sorted by spend desc: team-2 (20.3) then team-1 (15.5)
|
||||
expect(result[0].metrics.spend).toBe(20.3);
|
||||
expect(result[1].metrics.spend).toBe(15.5);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe("generateDailyData with aggregated data", () => {
|
||||
it("should produce rows from api_keys when entities is empty", () => {
|
||||
const result = generateDailyData(aggregatedSpendData, "Team");
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(result[0]).toHaveProperty("Date");
|
||||
expect(result[0]).toHaveProperty("Team");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateDailyWithKeysData with aggregated data", () => {
|
||||
it("should produce rows from api_keys when entities is empty", () => {
|
||||
const result = generateDailyWithKeysData(aggregatedSpendData, "Team");
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
|
||||
// Should have 3 key rows (key1, key1b, key2)
|
||||
expect(result).toHaveLength(3);
|
||||
const keyIds = result.map((r) => r["Key ID"]);
|
||||
expect(keyIds).toContain("key1");
|
||||
expect(keyIds).toContain("key1b");
|
||||
expect(keyIds).toContain("key2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateDailyWithModelsData with aggregated data", () => {
|
||||
it("should produce rows from api_keys when entities is empty", () => {
|
||||
const result = generateDailyWithModelsData(aggregatedSpendData, "Team");
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(result[0]).toHaveProperty("Model");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,49 @@ const extractTeamIdFromApiKeyBreakdown = (apiKeyBreakdown: Record<string, any> |
|
||||
return null;
|
||||
};
|
||||
|
||||
// Mirrors backend SpendMetrics fields (litellm/types/activity_tracking.py).
|
||||
// If the backend adds a field, add it here too.
|
||||
const METRIC_KEYS = [
|
||||
"spend", "api_requests", "successful_requests", "failed_requests",
|
||||
"total_tokens", "prompt_tokens", "completion_tokens",
|
||||
"cache_read_input_tokens", "cache_creation_input_tokens",
|
||||
] as const;
|
||||
|
||||
// When breakdown.entities is empty (aggregated endpoint), reconstruct entities
|
||||
// from breakdown.api_keys by grouping on metadata.team_id.
|
||||
const aggregateApiKeysIntoEntities = (breakdown: Record<string, any>): Record<string, any> => {
|
||||
const apiKeys = breakdown.api_keys;
|
||||
if (!apiKeys || Object.keys(apiKeys).length === 0) return {};
|
||||
|
||||
const grouped: Record<string, any> = {};
|
||||
|
||||
for (const [keyId, keyData] of Object.entries<any>(apiKeys)) {
|
||||
const teamId = keyData?.metadata?.team_id || "Unassigned";
|
||||
if (!grouped[teamId]) {
|
||||
grouped[teamId] = {
|
||||
metrics: Object.fromEntries(METRIC_KEYS.map((k) => [k, 0])),
|
||||
api_key_breakdown: {},
|
||||
};
|
||||
}
|
||||
const m = grouped[teamId].metrics;
|
||||
const km = keyData?.metrics || {};
|
||||
for (const k of METRIC_KEYS) {
|
||||
m[k] += km[k] || 0;
|
||||
}
|
||||
grouped[teamId].api_key_breakdown[keyId] = keyData;
|
||||
}
|
||||
|
||||
return grouped;
|
||||
};
|
||||
|
||||
// Returns breakdown.entities if populated, otherwise falls back to
|
||||
// reconstructing entities from breakdown.api_keys.
|
||||
export const resolveEntities = (breakdown: Record<string, any>): Record<string, any> => {
|
||||
const entities = breakdown.entities;
|
||||
if (entities && Object.keys(entities).length > 0) return entities;
|
||||
return aggregateApiKeysIntoEntities(breakdown);
|
||||
};
|
||||
|
||||
export const getEntityBreakdown = (
|
||||
spendData: EntitySpendData,
|
||||
teamAliasMap: Record<string, string> = {},
|
||||
@@ -24,7 +67,7 @@ export const getEntityBreakdown = (
|
||||
const entitySpend: { [key: string]: EntityBreakdown } = {};
|
||||
|
||||
spendData.results.forEach((day) => {
|
||||
Object.entries(day.breakdown.entities || {}).forEach(([entity, data]: [string, any]) => {
|
||||
Object.entries(resolveEntities(day.breakdown)).forEach(([entity, data]: [string, any]) => {
|
||||
// Extract team_id from api_key_breakdown metadata (not data.metadata which is empty)
|
||||
const teamId = extractTeamIdFromApiKeyBreakdown(data.api_key_breakdown) || entity;
|
||||
// Extract key_alias from the first API key that has one
|
||||
@@ -80,7 +123,7 @@ export const generateDailyData = (
|
||||
const dailyBreakdown: any[] = [];
|
||||
|
||||
spendData.results.forEach((day) => {
|
||||
Object.entries(day.breakdown.entities || {}).forEach(([entity, data]: [string, any]) => {
|
||||
Object.entries(resolveEntities(day.breakdown)).forEach(([entity, data]: [string, any]) => {
|
||||
// Extract team_id from api_key_breakdown metadata (not data.metadata which is empty)
|
||||
const teamId = extractTeamIdFromApiKeyBreakdown(data.api_key_breakdown);
|
||||
const teamAlias = teamId ? teamAliasMap[teamId] || null : null;
|
||||
@@ -129,7 +172,7 @@ export const generateDailyWithKeysData = (
|
||||
} = {};
|
||||
|
||||
spendData.results.forEach((day) => {
|
||||
Object.entries(day.breakdown.entities || {}).forEach(([entity, data]: [string, any]) => {
|
||||
Object.entries(resolveEntities(day.breakdown)).forEach(([entity, data]: [string, any]) => {
|
||||
const apiKeyBreakdown = data.api_key_breakdown || {};
|
||||
|
||||
// Iterate through each API key in the breakdown
|
||||
@@ -202,7 +245,7 @@ export const generateDailyWithModelsData = (
|
||||
spendData.results.forEach((day) => {
|
||||
const dailyEntityModels: { [key: string]: { [key: string]: any } } = {};
|
||||
|
||||
Object.entries(day.breakdown.entities || {}).forEach(([entity, entityData]: [string, any]) => {
|
||||
Object.entries(resolveEntities(day.breakdown)).forEach(([entity, entityData]: [string, any]) => {
|
||||
if (!dailyEntityModels[entity]) {
|
||||
dailyEntityModels[entity] = {};
|
||||
}
|
||||
@@ -230,7 +273,7 @@ export const generateDailyWithModelsData = (
|
||||
});
|
||||
|
||||
Object.entries(dailyEntityModels).forEach(([entity, models]) => {
|
||||
const entityData = day.breakdown.entities?.[entity];
|
||||
const entityData = resolveEntities(day.breakdown)[entity];
|
||||
// Extract team_id from api_key_breakdown metadata (not entityData.metadata which is empty)
|
||||
const teamId = extractTeamIdFromApiKeyBreakdown(entityData?.api_key_breakdown);
|
||||
const teamAlias = teamId ? teamAliasMap[teamId] || null : null;
|
||||
|
||||
Reference in New Issue
Block a user