Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions litellm/proxy/spend_tracking/spend_management_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -2254,6 +2254,10 @@ async def ui_view_spend_logs(
status_filter: str | None = fastapi.Query(
default=None, description="Filter logs by status (e.g., success, failure)"
),
cache_hit_filter: str | None = fastapi.Query(
default=None,
description="Filter logs by cache state: 'hit' or 'miss'. Miss includes legacy rows with a null/unknown cache state",
),
model: str | None = fastapi.Query(default=None, description="Filter logs by model"),
model_id: str | None = fastapi.Query(
default=None,
Expand Down Expand Up @@ -2330,6 +2334,13 @@ async def ui_view_spend_logs(
param="sort_order",
code=status.HTTP_400_BAD_REQUEST,
)
if isinstance(cache_hit_filter, str) and cache_hit_filter not in {"hit", "miss"}:
raise ProxyException(
message=f"Invalid cache_hit_filter: {cache_hit_filter}. Must be one of: hit, miss",
type="bad_request",
param="cache_hit_filter",
code=status.HTTP_400_BAD_REQUEST,
)

try:
is_admin_view: Final = _is_admin_view_safe(user_api_key_dict=user_api_key_dict)
Expand Down Expand Up @@ -2570,6 +2581,11 @@ def parse_date(date_str: str) -> datetime:
sql_params.append(status_filter)
p += 1

if cache_hit_filter == "hit":
sql_conditions.append("LOWER(cache_hit) = 'true'")
elif cache_hit_filter == "miss":
sql_conditions.append("(cache_hit IS NULL OR LOWER(cache_hit) != 'true')")

if exclude_internal_health_checks:
sql_conditions.append(f"api_key NOT IN (${p}, ${p + 1})")
sql_params.extend(_INTERNAL_HEALTH_CHECK_API_KEYS)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@ def _iso(value):
where["OR"] = where.get("OR", []) + [{"multi_team": True}]
elif "status = 'success'" in cond:
where["OR"] = where.get("OR", []) + [{"status": "success"}]
elif cond == "LOWER(cache_hit) = 'true'":
where["cache_hit"] = "hit"
elif cond == "(cache_hit IS NULL OR LOWER(cache_hit) != 'true')":
where["cache_hit"] = "miss"
elif sess:
where["session_id"] = {"contains": str(params[int(sess.group(1)) - 1]).strip("%")}
elif status:
Expand Down Expand Up @@ -2444,6 +2448,96 @@ def filter_by_status(where):
app.dependency_overrides.pop(ps.user_api_key_auth, None)


@pytest.mark.asyncio
async def test_ui_view_spend_logs_with_cache_hit_filter(client, monkeypatch):
base = {
"api_key": "sk-test-key",
"user": "test_user_1",
"team_id": "team1",
"spend": 0.05,
"startTime": datetime.datetime.now(timezone.utc).isoformat(),
"model": "gpt-4",
"status": "success",
}
mock_spend_logs = [
{**base, "id": "log1", "request_id": "req-hit", "cache_hit": "True"},
{**base, "id": "log2", "request_id": "req-miss", "cache_hit": "False"},
{**base, "id": "log3", "request_id": "req-legacy", "cache_hit": "None"},
{**base, "id": "log4", "request_id": "req-null", "cache_hit": None},
]

def filter_by_cache(where):
cache_filter = where.get("cache_hit")
if cache_filter == "hit":
return [log for log in mock_spend_logs if str(log["cache_hit"]).lower() == "true"]
if cache_filter == "miss":
return [log for log in mock_spend_logs if str(log["cache_hit"]).lower() != "true"]
return mock_spend_logs

monkeypatch.setattr(
"litellm.proxy.proxy_server.prisma_client",
make_ui_spend_logs_mock_prisma(mock_spend_logs, filter_by_cache),
)

start_date, end_date = _default_date_range()

app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN
)
try:
response = client.get(
"/spend/logs/ui",
params={
"cache_hit_filter": "hit",
"start_date": start_date,
"end_date": end_date,
},
headers={"Authorization": "Bearer sk-test"},
)
assert response.status_code == 200
data = response.json()
assert data["total"] == 1
assert [row["request_id"] for row in data["data"]] == ["req-hit"]

response = client.get(
"/spend/logs/ui",
params={
"cache_hit_filter": "miss",
"start_date": start_date,
"end_date": end_date,
},
headers={"Authorization": "Bearer sk-test"},
)
assert response.status_code == 200
data = response.json()
assert data["total"] == 3
assert [row["request_id"] for row in data["data"]] == ["req-miss", "req-legacy", "req-null"]

response = client.get(
"/spend/logs/ui",
params={
"start_date": start_date,
"end_date": end_date,
},
headers={"Authorization": "Bearer sk-test"},
)
assert response.status_code == 200
assert response.json()["total"] == 4

response = client.get(
"/spend/logs/ui",
params={
"cache_hit_filter": "invalid",
"start_date": start_date,
"end_date": end_date,
},
headers={"Authorization": "Bearer sk-test"},
)
assert response.status_code == 400
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)


@pytest.mark.asyncio
async def test_ui_view_spend_logs_with_model(client, monkeypatch):
mock_spend_logs = [
Expand Down
1 change: 1 addition & 0 deletions ui/litellm-dashboard/src/components/networking.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@
})();
export let proxyBaseUrl: string | null = _initialWorkerUrl ?? defaultProxyBaseUrl;
if (isLocal != true) {
console.log = function () {};

Check warning on line 123 in ui/litellm-dashboard/src/components/networking.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected console statement. Only these console methods are allowed: warn, error
}

const getWindowLocation = () => {
Expand Down Expand Up @@ -235,7 +235,7 @@
organization_id: string;
organization_alias: string;
budget_id: string;
metadata: Record<string, any>;

Check warning on line 238 in ui/litellm-dashboard/src/components/networking.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
models: string[];
spend: number;
model_spend: Record<string, number>;
Expand All @@ -243,16 +243,16 @@
created_by: string;
updated_at: string;
updated_by: string;
litellm_budget_table: any; // Simplified to any since we don't need the detailed structure

Check warning on line 246 in ui/litellm-dashboard/src/components/networking.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
teams: any[] | null;

Check warning on line 247 in ui/litellm-dashboard/src/components/networking.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
users: any[] | null;

Check warning on line 248 in ui/litellm-dashboard/src/components/networking.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
members: any[] | null;

Check warning on line 249 in ui/litellm-dashboard/src/components/networking.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
object_permission?: ObjectPermission | null;
}

export interface CredentialItem {
credential_name: string;
credential_values: any;

Check warning on line 255 in ui/litellm-dashboard/src/components/networking.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
credential_info: {
custom_llm_provider?: string;
description?: string;
Expand Down Expand Up @@ -333,7 +333,7 @@

let lastErrorTime = 0;

export const handleError = async (errorData: string | any) => {

Check warning on line 336 in ui/litellm-dashboard/src/components/networking.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
const currentTime = Date.now();
if (currentTime - lastErrorTime > 60000) {
// 60000 milliseconds = 60 seconds
Expand Down Expand Up @@ -657,7 +657,7 @@

export const budgetCreateCall = async (
accessToken: string,
formValues: Record<string, any>, // Assuming formValues is an object

Check warning on line 660 in ui/litellm-dashboard/src/components/networking.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
) => {
try {
const data = await apiClient.post(`/budget/new`, {
Expand All @@ -676,7 +676,7 @@

export const budgetUpdateCall = async (
accessToken: string,
formValues: Record<string, any>, // Assuming formValues is an object

Check warning on line 679 in ui/litellm-dashboard/src/components/networking.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
) => {
try {
const data = await apiClient.post(`/budget/update`, {
Expand Down Expand Up @@ -2002,6 +2002,7 @@
user_id?: string;
end_user?: string;
status_filter?: string;
cache_hit_filter?: string;
/** Filter by model name (e.g. "gpt-4") */
model?: string;
/** Filter by model ID (litellm model deployment id) */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ describe("RequestLogsFilters", () => {
for (const label of [
"Team ID",
"Status",
"Cache",
"Key Alias",
"User ID",
"End User",
Expand Down Expand Up @@ -259,4 +260,37 @@ describe("RequestLogsFilters", () => {

expect(await screen.findByText(label)).toBeInTheDocument();
});

it.each([
["", "All Requests"],
["hit", "Cache Hit"],
["miss", "Cache Miss"],
])("shows the human label on the Cache trigger for %s", async (cacheState, label) => {
renderFilters(cacheState === "" ? {} : { [LOG_FILTER_IDS.CACHE_STATUS]: cacheState });

expect(await screen.findByText(label)).toBeInTheDocument();
});

it.each([
["Cache Hit", "hit"],
["Cache Miss", "miss"],
])("selecting %s sets the cache filter to %s", async (label, expected) => {
const user = userEvent.setup();
const { set } = renderFilters();

await user.click(await screen.findByText("All Requests"));
await user.click(await screen.findByRole("option", { name: label }));

expect(set).toHaveBeenCalledWith(LOG_FILTER_IDS.CACHE_STATUS, expected);
});

it("selecting All Requests clears the cache filter", async () => {
const user = userEvent.setup();
const { set } = renderFilters({ [LOG_FILTER_IDS.CACHE_STATUS]: "hit" });

await user.click(await screen.findByText("Cache Hit"));
await user.click(await screen.findByRole("option", { name: "All Requests" }));

expect(set).toHaveBeenCalledWith(LOG_FILTER_IDS.CACHE_STATUS, undefined);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ const STATUS_FILTER_ITEMS = [
{ value: "success", label: "Success" },
{ value: "failure", label: "Failure" },
] as const;

const CACHE_FILTER_ITEMS = [
{ value: ALL_VALUE, label: "All Requests" },
{ value: "hit", label: "Cache Hit" },
{ value: "miss", label: "Cache Miss" },
] as const;
const PAGE_SIZE = 50;

const asString = (value: unknown): string => (typeof value === "string" ? value : "");
Expand Down Expand Up @@ -328,6 +334,27 @@ export function RequestLogsFilters({ get, set, teams, logsWindow }: RequestLogsF
</Select>
</DataTableFilterField>

<DataTableFilterField label="Cache">
<Select
items={CACHE_FILTER_ITEMS}
value={valueOf(LOG_FILTER_IDS.CACHE_STATUS) === "" ? ALL_VALUE : valueOf(LOG_FILTER_IDS.CACHE_STATUS)}
onValueChange={(next) =>
set(LOG_FILTER_IDS.CACHE_STATUS, next === null || next === ALL_VALUE ? undefined : next)
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="All Requests" />
</SelectTrigger>
<SelectContent>
{CACHE_FILTER_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</DataTableFilterField>

<KeyAliasFilterField
value={valueOf(LOG_FILTER_IDS.KEY_ALIAS)}
onChange={setter(LOG_FILTER_IDS.KEY_ALIAS)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ describe("useLogFilterLogic", () => {
{ id: LOG_FILTER_IDS.SESSION_ID, value: "sess-1", param: "session_id" },
{ id: LOG_FILTER_IDS.END_USER, value: "end-user-1", param: "end_user" },
{ id: LOG_FILTER_IDS.STATUS, value: "failure", param: "status_filter" },
{ id: LOG_FILTER_IDS.CACHE_STATUS, value: "hit", param: "cache_hit_filter" },
{ id: LOG_FILTER_IDS.CACHE_STATUS, value: "miss", param: "cache_hit_filter" },
{ id: LOG_FILTER_IDS.MODEL_ID, value: "model-uuid-1", param: "model_id" },
{ id: LOG_FILTER_IDS.PUBLIC_MODEL_OR_SEARCH_TOOL, value: "gpt-4o", param: "model" },
{ id: LOG_FILTER_IDS.KEY_ALIAS, value: "alias-1", param: "key_alias" },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export interface PaginatedResponse {
export const LOG_FILTER_IDS = {
TEAM_ID: "team_id",
STATUS: "status",
CACHE_STATUS: "cache_hit",
KEY_ALIAS: "key_alias",
END_USER: "end_user",
ERROR_CODE: "error_code",
Expand All @@ -35,6 +36,7 @@ export const LOG_FILTER_IDS = {
export const LOG_FILTER_LABELS: Record<string, string> = {
[LOG_FILTER_IDS.TEAM_ID]: "Team ID",
[LOG_FILTER_IDS.STATUS]: "Status",
[LOG_FILTER_IDS.CACHE_STATUS]: "Cache",
[LOG_FILTER_IDS.KEY_ALIAS]: "Key Alias",
[LOG_FILTER_IDS.USER_ID]: "User ID",
[LOG_FILTER_IDS.END_USER]: "End User",
Expand Down Expand Up @@ -170,6 +172,7 @@ export function useLogFilterLogic({
user_id: userIdFilter,
end_user: getFilterValue(columnFilters, LOG_FILTER_IDS.END_USER),
status_filter: getFilterValue(columnFilters, LOG_FILTER_IDS.STATUS),
cache_hit_filter: getFilterValue(columnFilters, LOG_FILTER_IDS.CACHE_STATUS),
model_id: getFilterValue(columnFilters, LOG_FILTER_IDS.MODEL_ID),
model: getFilterValue(columnFilters, LOG_FILTER_IDS.PUBLIC_MODEL_OR_SEARCH_TOOL),
key_alias: getFilterValue(columnFilters, LOG_FILTER_IDS.KEY_ALIAS),
Expand Down
4 changes: 4 additions & 0 deletions ui/litellm-dashboard/src/lib/http/schema.d.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading