This commit is contained in:
ryan-crabbe-berri 2026-10-29 00:01:01 +00:00 • committed by GitHub
commit b54afa579f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 145 additions and 29 deletions

View file

@ -2575,7 +2575,7 @@ async def ui_view_spend_logs(
description="Filter logs by model ID (litellm model deployment id)",
),
model_group: str | None = fastapi.Query(default=None, description="Filter logs by model group"),
key_alias: str | None = fastapi.Query(default=None, description="Filter logs by key alias"),
key_alias: str | None = fastapi.Query(default=None, description="Filter logs by exact key alias match"),
end_user: str | None = fastapi.Query(default=None, description="Filter logs by end user"),
error_code: str | None = fastapi.Query(default=None, description="Filter logs by error code (e.g., '404', '500')"),
error_message: str | None = fastapi.Query(
@ -2754,14 +2754,6 @@ async def ui_view_spend_logs(
# Build metadata filters
metadata_filters: Final = []
if key_alias is not None:
metadata_filters.append(
{
"path": ["user_api_key_alias"],
"string_contains": key_alias,
}
)
if error_code is not None:
metadata_filters.append(
{
@ -2929,8 +2921,11 @@ async def ui_view_spend_logs(
# Metadata JSON filters (PostgreSQL JSONB operators)
if key_alias is not None:
sql_conditions.append(f"metadata->>'user_api_key_alias' LIKE ${p}")
sql_params.append(f"%{key_alias}%")
sql_conditions.append(
f'api_key IN (SELECT token FROM "LiteLLM_VerificationToken" WHERE key_alias = ${p} '
f'UNION SELECT token FROM "LiteLLM_DeletedVerificationToken" WHERE key_alias = ${p})'
)
sql_params.append(key_alias)
p += 1
if error_code is not None:
sql_conditions.append(f"metadata->'error_information'->>'error_code' = ${p}")

View file

@ -4,14 +4,17 @@ from collections.abc import AsyncIterator
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from hashlib import sha256
from pathlib import Path
from typing import Final
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
import httpx
import psycopg
import pytest
import pytest_asyncio
from integration._support.client import Gateway, eventually, object_value, string_value
from integration._support.client import Gateway, delete_key_if_present, eventually, object_value, string_value
from integration._support.database import read_rows
from integration._support.process import owned_proxy_process
from prisma import Prisma
from psycopg import sql
from psycopg.types.json import Jsonb
@ -77,6 +80,37 @@ def _seed_rows(
)
def _seed_alias_rows(
connection: psycopg.Connection,
rows: tuple[tuple[str, str, str | None, str], ...],
session_id: str,
started: datetime,
) -> None:
utc_timestamp: Final = started.astimezone(timezone.utc).replace(tzinfo=None)
with connection.cursor() as cursor:
cursor.executemany(
sql.SQL(
'INSERT INTO {} (request_id, api_key, "user", session_id, "startTime", "endTime", '
"metadata, messages, response, call_type) "
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, 'acompletion')"
).format(sql.Identifier("public", "LiteLLM_SpendLogs")),
tuple(
(
request_id,
api_key,
user_id,
session_id,
utc_timestamp,
utc_timestamp,
Jsonb({"user_api_key_alias": key_alias}),
Jsonb([{"role": "user", "content": request_id + " payload"}]),
Jsonb({"id": request_id}),
)
for request_id, api_key, user_id, key_alias in rows
),
)
@pytest_asyncio.fixture(loop_scope="function")
async def spend_database() -> AsyncIterator[Prisma]:
schema: Final = f"integration_spend_scope_{uuid.uuid4().hex}"
@ -157,8 +191,8 @@ async def test_payload_sql_filters_foreign_collisions_and_prefers_exact_ids_for_
assert tuple(row.request_id for row in REQUEST_IDS.validate_python(result)) == expected
def _delete_session(session_id: str) -> None:
with psycopg.connect(os.environ["DATABASE_URL"]) as connection:
def _delete_session(session_id: str, database_url: str | None = None) -> None:
with psycopg.connect(database_url or os.environ["DATABASE_URL"]) as connection:
connection.execute('DELETE FROM "LiteLLM_SpendLogs" WHERE session_id = %s', (session_id,))
@ -240,6 +274,99 @@ def _chat_request_id(gateway: Gateway, model: str, key: str) -> str:
return str(body["id"])
def test_spend_log_key_alias_is_exact_includes_deleted_keys_and_preserves_scope(tmp_path: Path) -> None:
with httpx.Client(base_url="http://127.0.0.1:1", timeout=15, trust_env=False) as source_client:
source_gateway: Final = Gateway(source_client, "sk-integration-master", "http://127.0.0.1:8190")
with owned_proxy_process(source_gateway, tmp_path, {}) as owned:
gateway: Final = owned.gateway
with gateway.scenario() as scenario:
assert gateway.database_url is not None
database_url: Final = gateway.database_url
owner: Final = scenario.user(user_role="internal_user")
other_owner: Final = scenario.user(user_role="internal_user")
key_alias: Final = f"prod-key-{uuid.uuid4().hex}"
overlapping_key_alias: Final = f"{key_alias}-2"
deleted_key_alias: Final = f"deleted-key-{uuid.uuid4().hex}"
key: Final = scenario.key(user_id=owner, key_alias=key_alias)
overlapping_key: Final = scenario.key(
user_id=other_owner,
key_alias=overlapping_key_alias,
)
deleted_key_response: Final = gateway.post(
"/key/generate",
{"key_alias": deleted_key_alias},
)
deleted_key: Final = string_value(deleted_key_response["key"])
scenario.cleanups.callback(delete_key_if_present, gateway, deleted_key)
request_ids: Final = tuple(f"alias-{uuid.uuid4().hex}" for _ in range(3))
session_id: Final = f"key-alias-{uuid.uuid4().hex}"
started: Final = datetime.now(timezone.utc) - timedelta(hours=1)
scenario.cleanups.callback(_delete_session, session_id, database_url)
with psycopg.connect(database_url) as connection:
_seed_alias_rows(
connection,
(
(request_ids[0], sha256(key.encode()).hexdigest(), owner, key_alias),
(
request_ids[1],
sha256(overlapping_key.encode()).hexdigest(),
other_owner,
overlapping_key_alias,
),
(
request_ids[2],
sha256(deleted_key.encode()).hexdigest(),
other_owner,
deleted_key_alias,
),
),
session_id,
started,
)
gateway.post("/key/delete", {"keys": [deleted_key]})
date_filters: Final = {
"start_date": started.strftime("%Y-%m-%d %H:%M:%S"),
"end_date": (started + timedelta(hours=2)).strftime("%Y-%m-%d %H:%M:%S"),
}
for path in ("/spend/logs/ui", "/spend/logs/v2"):
exact: Final = gateway.request(
"GET",
path,
params={**date_filters, "key_alias": key_alias},
)
assert exact.status_code == 200, exact.text
assert exact.json()["total"] == 1, exact.text
assert [row["request_id"] for row in exact.json()["data"]] == [request_ids[0]], exact.text
deleted: Final = gateway.request(
"GET",
path,
params={**date_filters, "key_alias": deleted_key_alias},
)
assert deleted.status_code == 200, deleted.text
assert deleted.json()["total"] == 1, deleted.text
assert [row["request_id"] for row in deleted.json()["data"]] == [request_ids[2]], deleted.text
absent: Final = gateway.request(
"GET",
path,
params={**date_filters, "key_alias": f"absent-{uuid.uuid4().hex}"},
)
assert absent.status_code == 200, absent.text
assert absent.json()["total"] == 0, absent.text
assert absent.json()["data"] == [], absent.text
foreign_alias: Final = gateway.request(
"GET",
"/spend/logs/ui",
params={**date_filters, "key_alias": overlapping_key_alias},
key=key,
)
assert foreign_alias.status_code == 200, foreign_alias.text
assert foreign_alias.json()["data"] == [], foreign_alias.text
def _legacy_spend_rows(gateway: Gateway, key: str, params: dict[str, str]) -> list[dict[str, object]]:
response: Final = gateway.request("GET", "/spend/logs", key=key, params=params)
assert response.status_code == 200, response.text

View file

@ -4846,7 +4846,7 @@ async def test_ui_view_spend_logs_with_error_message(client):
@pytest.mark.asyncio
async def test_ui_view_spend_logs_with_error_code_and_key_alias(client):
"""Test merging error_code and key_alias filters with AND logic"""
"""Test error-code filtering when a key-alias filter is supplied"""
mock_spend_logs = [
{
"id": "log1",
@ -4868,7 +4868,7 @@ async def test_ui_view_spend_logs_with_error_code_and_key_alias(client):
"spend": 0.10,
"startTime": datetime.datetime.now(timezone.utc).isoformat(),
"model": "gpt-4",
"metadata": '{"user_api_key_alias": "test-key-2", "error_information": {"error_code": "500"}}',
"metadata": '{"user_api_key_alias": "test-key-2", "error_information": {"error_code": "404"}}',
},
{
"id": "log3",
@ -4884,17 +4884,11 @@ async def test_ui_view_spend_logs_with_error_code_and_key_alias(client):
]
def filter_by_error_code_and_key_alias(where):
if "AND" in where:
key_alias = error_code = None
for cond in where["AND"]:
if "metadata" in cond:
mf = cond["metadata"]
if mf.get("path") == ["user_api_key_alias"]:
key_alias = mf.get("string_contains")
elif mf.get("path") == ["error_information", "error_code"]:
error_code = str(mf.get("equals", "")).strip('"')
if key_alias == "test-key-1" and error_code == "500":
return [mock_spend_logs[2]]
metadata_filter = where.get("metadata", {})
if metadata_filter.get("path") == ["error_information", "error_code"] and str(
metadata_filter.get("equals", "")
).strip('"') == "500":
return [mock_spend_logs[2]]
return mock_spend_logs
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(

View file

@ -72176,7 +72176,7 @@ export interface operations {
model_id?: string | null;
/** @description Filter logs by model group */
model_group?: string | null;
/** @description Filter logs by key alias */
/** @description Filter logs by exact key alias match */
key_alias?: string | null;
/** @description Filter logs by end user */
end_user?: string | null;
@ -72298,7 +72298,7 @@ export interface operations {
model_id?: string | null;
/** @description Filter logs by model group */
model_group?: string | null;
/** @description Filter logs by key alias */
/** @description Filter logs by exact key alias match */
key_alias?: string | null;
/** @description Filter logs by end user */
end_user?: string | null;