feat(ui): add cache hit/miss filter to Request Logs (#38432)

* feat(ui): add cache hit/miss filter to Request Logs

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix: guard cache_hit_filter validation for direct handler calls

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* chore(ui): drop redundant cache filter comment

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: yassin <yassin@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-08-27 17:57:07 +00:00 committed by GitHub
parent 62341e96ae
commit a7da7928fa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 181 additions and 0 deletions

View file

@ -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,
@ -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)
@ -2570,6 +2581,11 @@ async def ui_view_spend_logs(
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)

View file

@ -106,6 +106,10 @@ def _reconstruct_ui_where_from_sql(sql_query, params):
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:
@ -2444,6 +2448,96 @@ async def test_ui_view_spend_logs_with_status(client, monkeypatch):
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 = [

View file

@ -2002,6 +2002,7 @@ interface UiSpendLogsParams {
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) */

View file

@ -69,6 +69,7 @@ describe("RequestLogsFilters", () => {
for (const label of [
"Team ID",
"Status",
"Cache",
"Key Alias",
"User ID",
"End User",
@ -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);
});
});

View file

@ -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 : "");
@ -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)}

View file

@ -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" },

View file

@ -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",
@ -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",
@ -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),

View file

@ -55030,6 +55030,8 @@ export interface operations {
page_size?: number;
/** @description Filter logs by status (e.g., success, failure) */
status_filter?: string | null;
/** @description Filter logs by cache state: 'hit' or 'miss'. Miss includes legacy rows with a null/unknown cache state */
cache_hit_filter?: string | null;
/** @description Filter logs by model */
model?: string | null;
/** @description Filter logs by model ID (litellm model deployment id) */
@ -55140,6 +55142,8 @@ export interface operations {
page_size?: number;
/** @description Filter logs by status (e.g., success, failure) */
status_filter?: string | null;
/** @description Filter logs by cache state: 'hit' or 'miss'. Miss includes legacy rows with a null/unknown cache state */
cache_hit_filter?: string | null;
/** @description Filter logs by model */
model?: string | null;
/** @description Filter logs by model ID (litellm model deployment id) */