mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-25 05:54:53 +00:00
Merge b6d941170b into 6762d65eb8
This commit is contained in:
commit
2099fb33ad
11 changed files with 350 additions and 79 deletions
|
|
@ -3797,6 +3797,7 @@
|
|||
"daily",
|
||||
"daily_with_keys",
|
||||
"daily_with_models",
|
||||
"daily_with_keys_and_models",
|
||||
"daily_with_users"
|
||||
],
|
||||
"title": "ExportType",
|
||||
|
|
|
|||
|
|
@ -138,16 +138,23 @@ def _daily_rows_table(
|
|||
|
||||
def _next_export_cursor(batch: tuple[ExportRow, ...], export_type: ExportType) -> ExportCursor:
|
||||
last: Final = batch[-1]
|
||||
cursor_key: Final = (
|
||||
last.api_key
|
||||
if export_type is ExportType.DAILY_WITH_KEYS
|
||||
else last.model
|
||||
if export_type is ExportType.DAILY_WITH_MODELS
|
||||
else last.user_id
|
||||
if export_type is ExportType.DAILY_WITH_USERS
|
||||
else ""
|
||||
)
|
||||
return ExportCursor(date=last.date, entity_id=last.entity_id, group_key=cursor_key or "")
|
||||
match export_type:
|
||||
case ExportType.DAILY:
|
||||
return ExportCursor(date=last.date, entity_id=last.entity_id, group_key=())
|
||||
case ExportType.DAILY_WITH_KEYS:
|
||||
return ExportCursor(date=last.date, entity_id=last.entity_id, group_key=(last.api_key or "",))
|
||||
case ExportType.DAILY_WITH_MODELS:
|
||||
return ExportCursor(date=last.date, entity_id=last.entity_id, group_key=(last.model or "",))
|
||||
case ExportType.DAILY_WITH_KEYS_AND_MODELS:
|
||||
return ExportCursor(
|
||||
date=last.date,
|
||||
entity_id=last.entity_id,
|
||||
group_key=(last.api_key or "", last.model or ""),
|
||||
)
|
||||
case ExportType.DAILY_WITH_USERS:
|
||||
return ExportCursor(date=last.date, entity_id=last.entity_id, group_key=(last.user_id or "",))
|
||||
case _:
|
||||
assert_never(export_type)
|
||||
|
||||
|
||||
class DailyActivityRepository:
|
||||
|
|
|
|||
|
|
@ -13,6 +13,21 @@ from litellm.types.repositories.daily_activity import DailyActivityScope, DailyA
|
|||
|
||||
_API_KEY_ROLLED_UP_BIT: Final = 32
|
||||
_MODEL_GROUP_EXPR: Final = "COALESCE(NULLIF(model_group, ''), model)"
|
||||
_EXPORT_KEY_USER_FIELDS: Final = (
|
||||
"MAX(COALESCE(vt.key_alias, dvt.key_alias)) AS key_alias, "
|
||||
"MAX(COALESCE(vt.user_id, dvt.user_id)) AS user_id, MAX(u.user_email) AS user_email"
|
||||
)
|
||||
_EXPORT_KEY_METADATA_JOINS: Final[tuple[str, ...]] = (
|
||||
'LEFT JOIN "LiteLLM_VerificationToken" vt ON vt.token = scoped.api_key',
|
||||
"""LEFT JOIN LATERAL (
|
||||
SELECT key_alias, user_id
|
||||
FROM "LiteLLM_DeletedVerificationToken"
|
||||
WHERE token = scoped.api_key
|
||||
ORDER BY deleted_at DESC
|
||||
LIMIT 1
|
||||
) dvt ON vt.token IS NULL""",
|
||||
'LEFT JOIN "LiteLLM_UserTable" u ON u.user_id = COALESCE(vt.user_id, dvt.user_id)',
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
@ -25,7 +40,7 @@ class SqlQuery:
|
|||
class ExportCursor:
|
||||
date: str
|
||||
entity_id: str
|
||||
group_key: str
|
||||
group_key: tuple[str, ...]
|
||||
|
||||
|
||||
PRISMA_TO_PG_TABLE: Final[Mapping[DailyActivityTable, str]] = MappingProxyType(
|
||||
|
|
@ -423,10 +438,10 @@ def build_export_sql(
|
|||
) -> SqlQuery:
|
||||
_bounded_limit(batch_size)
|
||||
where_clause, where_params = build_where_clause(scope)
|
||||
group_key, output_key, user_fields, type_joins = _export_grouping(export_type)
|
||||
group_keys, output_key, user_fields, type_joins = _export_grouping(export_type)
|
||||
grouping_keys: Final = (
|
||||
f"scoped.date, COALESCE(scoped.\"{scope.entity_id_field}\", '')",
|
||||
*((group_key,) if export_type is not ExportType.DAILY else ()),
|
||||
*group_keys,
|
||||
)
|
||||
entity_joins: Final = (
|
||||
('LEFT JOIN "LiteLLM_TeamTable" tt ON tt.team_id = scoped.team_id',)
|
||||
|
|
@ -445,14 +460,19 @@ def build_export_sql(
|
|||
)
|
||||
parameter_indexes: Final = count(len(where_params) + 1)
|
||||
sentinel_param: Final = next(parameter_indexes) if export_type is not ExportType.DAILY else None
|
||||
cursor_indexes: Final = tuple(islice(parameter_indexes, 3)) if after is not None else ()
|
||||
cursor_indexes: Final = tuple(islice(parameter_indexes, 2 + len(group_keys))) if after is not None else ()
|
||||
limit_param: Final = next(parameter_indexes)
|
||||
cursor_clause, cursor_params = _export_cursor_clause(
|
||||
scope, after=after, cursor_indexes=cursor_indexes, group_key=group_key
|
||||
scope, after=after, cursor_indexes=cursor_indexes, group_keys=group_keys
|
||||
)
|
||||
sentinel_clause: Final = f" AND api_key <> ${sentinel_param}" if sentinel_param is not None else ""
|
||||
table: Final = PRISMA_TO_PG_TABLE[scope.table]
|
||||
flat_cost: Final = _ptu_flat_cost_select(scope.table, aggregate=False)
|
||||
model_expression: Final = (
|
||||
"NULLIF(COALESCE(scoped.model, ''), '')"
|
||||
if export_type in (ExportType.DAILY_WITH_MODELS, ExportType.DAILY_WITH_KEYS_AND_MODELS)
|
||||
else "NULL::text"
|
||||
)
|
||||
sql: Final = f"""
|
||||
WITH scoped AS (
|
||||
SELECT * FROM "{table}"
|
||||
|
|
@ -464,7 +484,7 @@ def build_export_sql(
|
|||
{alias_expression} AS entity_alias,
|
||||
{output_key} AS api_key,
|
||||
{user_fields},
|
||||
{"NULLIF(COALESCE(scoped.model, ''), '')" if export_type is ExportType.DAILY_WITH_MODELS else "NULL::text"} AS model,
|
||||
{model_expression} AS model,
|
||||
COALESCE(SUM(scoped.spend), 0)::float AS spend,
|
||||
{flat_cost} AS flat_cost,
|
||||
COALESCE(SUM(scoped.prompt_tokens), 0)::bigint AS prompt_tokens,
|
||||
|
|
@ -492,56 +512,40 @@ def build_export_sql(
|
|||
)
|
||||
|
||||
|
||||
def _export_grouping(export_type: ExportType) -> tuple[str, str, str, tuple[str, ...]]:
|
||||
def _export_grouping(export_type: ExportType) -> tuple[tuple[str, ...], str, str, tuple[str, ...]]:
|
||||
if export_type is ExportType.DAILY:
|
||||
return (
|
||||
"''",
|
||||
(),
|
||||
"NULL::text",
|
||||
"NULL::text AS key_alias, NULL::text AS user_id, NULL::text AS user_email",
|
||||
(),
|
||||
)
|
||||
if export_type is ExportType.DAILY_WITH_KEYS:
|
||||
if export_type in (ExportType.DAILY_WITH_KEYS, ExportType.DAILY_WITH_KEYS_AND_MODELS):
|
||||
group_keys: Final = (
|
||||
("scoped.api_key",)
|
||||
if export_type is ExportType.DAILY_WITH_KEYS
|
||||
else ("scoped.api_key", "COALESCE(scoped.model, '')")
|
||||
)
|
||||
return (
|
||||
"scoped.api_key",
|
||||
group_keys,
|
||||
"NULLIF(scoped.api_key, '')",
|
||||
"MAX(COALESCE(vt.key_alias, dvt.key_alias)) AS key_alias, "
|
||||
"MAX(COALESCE(vt.user_id, dvt.user_id)) AS user_id, MAX(u.user_email) AS user_email",
|
||||
(
|
||||
'LEFT JOIN "LiteLLM_VerificationToken" vt ON vt.token = scoped.api_key',
|
||||
"""LEFT JOIN LATERAL (
|
||||
SELECT key_alias, user_id
|
||||
FROM "LiteLLM_DeletedVerificationToken"
|
||||
WHERE token = scoped.api_key
|
||||
ORDER BY deleted_at DESC
|
||||
LIMIT 1
|
||||
) dvt ON vt.token IS NULL""",
|
||||
'LEFT JOIN "LiteLLM_UserTable" u ON u.user_id = COALESCE(vt.user_id, dvt.user_id)',
|
||||
),
|
||||
_EXPORT_KEY_USER_FIELDS,
|
||||
_EXPORT_KEY_METADATA_JOINS,
|
||||
)
|
||||
if export_type is ExportType.DAILY_WITH_MODELS:
|
||||
return (
|
||||
"COALESCE(scoped.model, '')",
|
||||
("COALESCE(scoped.model, '')",),
|
||||
"NULL::text",
|
||||
"NULL::text AS key_alias, NULL::text AS user_id, NULL::text AS user_email",
|
||||
(),
|
||||
)
|
||||
if export_type is ExportType.DAILY_WITH_USERS:
|
||||
return (
|
||||
"COALESCE(vt.user_id, dvt.user_id, '')",
|
||||
("COALESCE(vt.user_id, dvt.user_id, '')",),
|
||||
"NULL::text",
|
||||
"NULL::text AS key_alias, MAX(COALESCE(vt.user_id, dvt.user_id)) AS user_id, "
|
||||
"MAX(u.user_email) AS user_email",
|
||||
(
|
||||
'LEFT JOIN "LiteLLM_VerificationToken" vt ON vt.token = scoped.api_key',
|
||||
"""LEFT JOIN LATERAL (
|
||||
SELECT key_alias, user_id
|
||||
FROM "LiteLLM_DeletedVerificationToken"
|
||||
WHERE token = scoped.api_key
|
||||
ORDER BY deleted_at DESC
|
||||
LIMIT 1
|
||||
) dvt ON vt.token IS NULL""",
|
||||
'LEFT JOIN "LiteLLM_UserTable" u ON u.user_id = COALESCE(vt.user_id, dvt.user_id)',
|
||||
),
|
||||
_EXPORT_KEY_METADATA_JOINS,
|
||||
)
|
||||
assert_never(export_type)
|
||||
raise AssertionError("unreachable")
|
||||
|
|
@ -552,13 +556,15 @@ def _export_cursor_clause(
|
|||
*,
|
||||
after: ExportCursor | None,
|
||||
cursor_indexes: tuple[int, ...],
|
||||
group_key: str,
|
||||
group_keys: tuple[str, ...],
|
||||
) -> tuple[str, tuple[object, ...]]:
|
||||
if after is None:
|
||||
return "", ()
|
||||
first_cursor_index: Final = cursor_indexes[0]
|
||||
clause: Final = (
|
||||
f""" AND (scoped.date, COALESCE(scoped."{scope.entity_id_field}", ''), {group_key}) """
|
||||
f"> (${first_cursor_index}, ${cursor_indexes[1]}, ${cursor_indexes[2]})"
|
||||
cursor_expressions: Final = (
|
||||
"scoped.date",
|
||||
f"""COALESCE(scoped."{scope.entity_id_field}", '')""",
|
||||
*group_keys,
|
||||
)
|
||||
return clause, (after.date, after.entity_id, after.group_key)
|
||||
cursor_placeholders: Final = tuple(f"${index}" for index in cursor_indexes)
|
||||
clause: Final = f" AND ({', '.join(cursor_expressions)}) > ({', '.join(cursor_placeholders)})"
|
||||
return clause, (after.date, after.entity_id, *after.group_key)
|
||||
|
|
|
|||
|
|
@ -104,6 +104,7 @@ class ExportType(str, Enum):
|
|||
DAILY = "daily"
|
||||
DAILY_WITH_KEYS = "daily_with_keys"
|
||||
DAILY_WITH_MODELS = "daily_with_models"
|
||||
DAILY_WITH_KEYS_AND_MODELS = "daily_with_keys_and_models"
|
||||
DAILY_WITH_USERS = "daily_with_users"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -262,6 +262,102 @@ async def test_repository_queries_and_exports_seeded_daily_activity(monkeypatch:
|
|||
assert metadata["key-target"].tags == ("archived",)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_key_and_model_export_keeps_group_metrics_and_pages_by_both_keys(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
async with _daily_activity_database() as database:
|
||||
await database.execute_raw(
|
||||
"""
|
||||
INSERT INTO "LiteLLM_DailyTeamSpend" (
|
||||
id, team_id, date, api_key, model, model_group, custom_llm_provider,
|
||||
mcp_namespaced_tool_name, endpoint, prompt_tokens, completion_tokens,
|
||||
cache_read_input_tokens, cache_creation_input_tokens, spend, api_requests,
|
||||
successful_requests, failed_requests, ptu_flat_cost, updated_at
|
||||
) VALUES
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19::timestamp),
|
||||
($20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38::timestamp),
|
||||
($39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57::timestamp)
|
||||
""",
|
||||
"key-model-a",
|
||||
"team-composite",
|
||||
"2026-06-01",
|
||||
"key-1",
|
||||
"model-a",
|
||||
"",
|
||||
"provider-a",
|
||||
None,
|
||||
"/v1/chat/completions",
|
||||
10,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0.0,
|
||||
"2026-06-01 12:00:00",
|
||||
"key-model-b",
|
||||
"team-composite",
|
||||
"2026-06-01",
|
||||
"key-1",
|
||||
"model-b",
|
||||
"",
|
||||
"provider-a",
|
||||
None,
|
||||
"/v1/chat/completions",
|
||||
20,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
3.0,
|
||||
3,
|
||||
3,
|
||||
0,
|
||||
0.0,
|
||||
"2026-06-01 12:00:00",
|
||||
"key-model-c",
|
||||
"team-composite",
|
||||
"2026-06-01",
|
||||
"key-2",
|
||||
"model-a",
|
||||
"",
|
||||
"provider-a",
|
||||
None,
|
||||
"/v1/chat/completions",
|
||||
30,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
5.0,
|
||||
5,
|
||||
5,
|
||||
0,
|
||||
0.0,
|
||||
"2026-06-01 12:00:00",
|
||||
)
|
||||
repository: Final = _repository(database)
|
||||
team_scope: Final = _scope(DailyActivityTable.TEAM, "team_id", "team-composite")
|
||||
monkeypatch.setattr(constants, "USAGE_EXPORT_BATCH_SIZE", 1)
|
||||
|
||||
daily_rows: Final = tuple(
|
||||
[row async for row in repository.export_rows(team_scope, export_type=ExportType.DAILY)]
|
||||
)
|
||||
key_model_rows: Final = tuple(
|
||||
[row async for row in repository.export_rows(team_scope, export_type=ExportType.DAILY_WITH_KEYS_AND_MODELS)]
|
||||
)
|
||||
|
||||
assert len(daily_rows) == 1
|
||||
assert tuple((row.api_key, row.model, row.spend, row.api_requests) for row in key_model_rows) == (
|
||||
("key-1", "model-a", 2.0, 2),
|
||||
("key-1", "model-b", 3.0, 3),
|
||||
("key-2", "model-a", 5.0, 5),
|
||||
)
|
||||
assert sum(row.spend for row in key_model_rows) == daily_rows[0].spend == 10.0
|
||||
assert sum(row.api_requests for row in key_model_rows) == daily_rows[0].api_requests == 10
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("search", "api_keys", "expected_keys"),
|
||||
|
|
@ -684,9 +780,7 @@ async def test_user_page_ranks_users_and_folds_null_ids() -> None:
|
|||
repository: Final = _repository(database)
|
||||
full_page: Final = await repository.user_page(scope, offset=0, limit=50)
|
||||
empty_page: Final = await repository.user_page(scope, offset=full_page.total_users + 1, limit=50)
|
||||
scoped_page: Final = await repository.user_page(
|
||||
replace(scope, entity_ids=("user-alpha",)), offset=0, limit=50
|
||||
)
|
||||
scoped_page: Final = await repository.user_page(replace(scope, entity_ids=("user-alpha",)), offset=0, limit=50)
|
||||
|
||||
assert tuple((row.user_id, row.spend) for row in full_page.rows) == (
|
||||
(None, 45.0),
|
||||
|
|
|
|||
|
|
@ -212,6 +212,20 @@ def _assert_entity_activity_routes(
|
|||
export_rows: Final = tuple(csv.reader(io.StringIO(exported.text)))
|
||||
assert len(export_rows) == len(persisted_rows) + 1, exported.text
|
||||
|
||||
key_model_export: Final = gateway.request(
|
||||
"GET",
|
||||
f"{prefix}/daily/activity/export",
|
||||
params={**params, "export_type": "daily_with_keys_and_models"},
|
||||
)
|
||||
assert key_model_export.status_code == 200, key_model_export.text
|
||||
key_model_rows: Final = tuple(csv.reader(io.StringIO(key_model_export.text)))
|
||||
api_key_index: Final = key_model_rows[0].index("api_key")
|
||||
model_index: Final = key_model_rows[0].index("model")
|
||||
key_model_pairs: Final = tuple((row[api_key_index], row[model_index]) for row in key_model_rows[1:])
|
||||
assert key_model_pairs
|
||||
assert all(api_key and model_name for api_key, model_name in key_model_pairs), key_model_export.text
|
||||
assert len(key_model_pairs) == len(frozenset(key_model_pairs)), key_model_export.text
|
||||
|
||||
|
||||
@pytest.mark.timeout(90)
|
||||
def test_daily_activity_routes_cover_all_entities_and_bounded_key_search(gateway: Gateway, tmp_path: Path) -> None:
|
||||
|
|
|
|||
|
|
@ -104,21 +104,29 @@ def _user_spend_row(user_id: str | None, **overrides: object) -> dict[str, objec
|
|||
}
|
||||
|
||||
|
||||
def _export_row(api_key: str | None) -> dict[str, object]:
|
||||
def _export_row(
|
||||
api_key: str | None,
|
||||
*,
|
||||
model: str | None = None,
|
||||
user_id: str | None = None,
|
||||
entity_id: str = "user-1",
|
||||
spend: float = 1.0,
|
||||
api_requests: int = 1,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"date": "2026-01-01",
|
||||
"entity_id": "user-1",
|
||||
"entity_id": entity_id,
|
||||
"entity_alias": None,
|
||||
"api_key": api_key,
|
||||
"key_alias": None,
|
||||
"user_id": None,
|
||||
"user_id": user_id,
|
||||
"user_email": None,
|
||||
"model": None,
|
||||
"spend": 1.0,
|
||||
"model": model,
|
||||
"spend": spend,
|
||||
"flat_cost": 0.0,
|
||||
"prompt_tokens": 10,
|
||||
"completion_tokens": 2,
|
||||
"api_requests": 1,
|
||||
"api_requests": api_requests,
|
||||
"successful_requests": 1,
|
||||
"failed_requests": 0,
|
||||
"cache_read_input_tokens": 3,
|
||||
|
|
@ -704,8 +712,93 @@ async def test_export_is_lazy_and_uses_the_last_row_as_the_next_cursor(monkeypat
|
|||
build_export_sql(
|
||||
_scope(),
|
||||
export_type=ExportType.DAILY_WITH_KEYS,
|
||||
after=ExportCursor("2026-01-01", "user-1", "key-2"),
|
||||
after=ExportCursor("2026-01-01", "user-1", ("key-2",)),
|
||||
batch_size=2,
|
||||
).params
|
||||
== database.query_calls[1][1]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("export_type", "scope", "rows", "expected_cursor_params"),
|
||||
[
|
||||
(
|
||||
ExportType.DAILY,
|
||||
_scope(),
|
||||
(_export_row("key-1"),),
|
||||
(("2026-01-01", "user-1", 1),),
|
||||
),
|
||||
(
|
||||
ExportType.DAILY_WITH_MODELS,
|
||||
_scope(),
|
||||
(_export_row("key-1", model="model-a"), _export_row("key-1", model="model-b")),
|
||||
(
|
||||
("2026-01-01", "user-1", "model-a", 1),
|
||||
("2026-01-01", "user-1", "model-b", 1),
|
||||
),
|
||||
),
|
||||
(
|
||||
ExportType.DAILY_WITH_USERS,
|
||||
_scope(table=DailyActivityTable.TEAM),
|
||||
(
|
||||
_export_row("key-1", user_id="user-a", entity_id="team-1"),
|
||||
_export_row("key-2", user_id="user-b", entity_id="team-1"),
|
||||
),
|
||||
(
|
||||
("2026-01-01", "team-1", "user-a", 1),
|
||||
("2026-01-01", "team-1", "user-b", 1),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_export_pagination_preserves_existing_grouping_cursors(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
export_type: ExportType,
|
||||
scope: DailyActivityScope,
|
||||
rows: tuple[dict[str, object], ...],
|
||||
expected_cursor_params: tuple[tuple[object, ...], ...],
|
||||
) -> None:
|
||||
monkeypatch.setattr(constants, "USAGE_EXPORT_BATCH_SIZE", 1)
|
||||
database = _FakeDatabase(tuple((row,) for row in rows))
|
||||
repository, _ = _repository(database)
|
||||
|
||||
exported_rows: Final = tuple([row async for row in repository.export_rows(scope, export_type=export_type)])
|
||||
|
||||
assert len(exported_rows) == len(rows)
|
||||
cursor_param_count: Final = len(expected_cursor_params[0])
|
||||
next_page_params: Final = tuple(query[1][-cursor_param_count:] for query in database.query_calls[1:])
|
||||
assert next_page_params == expected_cursor_params
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_export_cursor_tracks_model_when_a_key_has_multiple_models(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(constants, "USAGE_EXPORT_BATCH_SIZE", 1)
|
||||
database = _FakeDatabase(
|
||||
(
|
||||
(_export_row("key-1", model="model-a"),),
|
||||
(_export_row("key-1", model="model-b"),),
|
||||
(_export_row("key-2", model="model-a"),),
|
||||
)
|
||||
)
|
||||
repository, _ = _repository(database)
|
||||
rows = repository.export_rows(_scope(), export_type=ExportType.DAILY_WITH_KEYS_AND_MODELS)
|
||||
|
||||
assert tuple([(row.api_key, row.model) async for row in rows]) == (
|
||||
("key-1", "model-a"),
|
||||
("key-1", "model-b"),
|
||||
("key-2", "model-a"),
|
||||
)
|
||||
assert len(database.query_calls) == 4
|
||||
assert database.query_calls[1][1][-5:] == ("2026-01-01", "user-1", "key-1", "model-a", 1)
|
||||
assert database.query_calls[2][1][-5:] == ("2026-01-01", "user-1", "key-1", "model-b", 1)
|
||||
assert database.query_calls[3][1][-5:] == ("2026-01-01", "user-1", "key-2", "model-a", 1)
|
||||
assert (
|
||||
build_export_sql(
|
||||
_scope(),
|
||||
export_type=ExportType.DAILY_WITH_KEYS_AND_MODELS,
|
||||
after=ExportCursor("2026-01-01", "user-1", ("key-1", "model-a")),
|
||||
batch_size=1,
|
||||
).params
|
||||
== database.query_calls[1][1]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -398,52 +398,88 @@ def test_query_builders_reject_nonpositive_limits(builder) -> None:
|
|||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("export_type", "group_key", "key_filter", "joins"),
|
||||
("export_type", "group_keys", "group_key_values", "key_filter", "joins"),
|
||||
[
|
||||
(ExportType.DAILY, "''", "", ""),
|
||||
(ExportType.DAILY_WITH_KEYS, "scoped.api_key", "api_key <> $3", 'LEFT JOIN "LiteLLM_VerificationToken"'),
|
||||
(ExportType.DAILY_WITH_MODELS, "COALESCE(scoped.model, '')", "api_key <> $3", ""),
|
||||
(ExportType.DAILY, (), (), "", ""),
|
||||
(
|
||||
ExportType.DAILY_WITH_KEYS,
|
||||
("scoped.api_key",),
|
||||
("group-3",),
|
||||
"api_key <> $3",
|
||||
'LEFT JOIN "LiteLLM_VerificationToken"',
|
||||
),
|
||||
(
|
||||
ExportType.DAILY_WITH_MODELS,
|
||||
("COALESCE(scoped.model, '')",),
|
||||
("group-3",),
|
||||
"api_key <> $3",
|
||||
"",
|
||||
),
|
||||
(
|
||||
ExportType.DAILY_WITH_KEYS_AND_MODELS,
|
||||
("scoped.api_key", "COALESCE(scoped.model, '')"),
|
||||
("group-3", "group-4"),
|
||||
"api_key <> $3",
|
||||
'LEFT JOIN "LiteLLM_VerificationToken"',
|
||||
),
|
||||
(
|
||||
ExportType.DAILY_WITH_USERS,
|
||||
"COALESCE(vt.user_id, dvt.user_id, '')",
|
||||
("COALESCE(vt.user_id, dvt.user_id, '')",),
|
||||
("group-3",),
|
||||
"api_key <> $3",
|
||||
'LEFT JOIN "LiteLLM_VerificationToken"',
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_export_groups_by_requested_key_and_binds_cursor_after_scope(
|
||||
export_type: ExportType, group_key: str, key_filter: str, joins: str
|
||||
export_type: ExportType,
|
||||
group_keys: tuple[str, ...],
|
||||
group_key_values: tuple[str, ...],
|
||||
key_filter: str,
|
||||
joins: str,
|
||||
) -> None:
|
||||
query = build_export_sql(
|
||||
_scope(entity_ids=None),
|
||||
export_type=export_type,
|
||||
after=ExportCursor(date="2026-01-12", entity_id="user-2", group_key="group-3"),
|
||||
after=ExportCursor(date="2026-01-12", entity_id="user-2", group_key=group_key_values),
|
||||
batch_size=2,
|
||||
)
|
||||
|
||||
assert group_key in query.sql
|
||||
assert all(group_key in query.sql for group_key in group_keys)
|
||||
assert key_filter in query.sql
|
||||
assert joins in query.sql
|
||||
assert "(scoped.date, COALESCE(scoped.\"user_id\", '')," in query.sql
|
||||
order_keys: Final = (
|
||||
"scoped.date, COALESCE(scoped.\"user_id\", '')",
|
||||
*((group_key,) if export_type is not ExportType.DAILY else ()),
|
||||
cursor_columns: Final = (
|
||||
"scoped.date",
|
||||
"""COALESCE(scoped."user_id", '')""",
|
||||
*group_keys,
|
||||
)
|
||||
assert f"ORDER BY {', '.join(order_keys)}" in query.sql
|
||||
expected_limit_index: Final = "$6" if export_type is ExportType.DAILY else "$7"
|
||||
assert f"GROUP BY {', '.join(cursor_columns)}" in query.sql
|
||||
assert f"ORDER BY {', '.join(cursor_columns)}" in query.sql
|
||||
cursor_start_index: Final = 3 + int(export_type is not ExportType.DAILY)
|
||||
cursor_placeholders: Final = tuple(
|
||||
f"${index}" for index in range(cursor_start_index, cursor_start_index + len(cursor_columns))
|
||||
)
|
||||
assert f"AND ({', '.join(cursor_columns)}) > ({', '.join(cursor_placeholders)})" in query.sql
|
||||
expected_limit_index: Final = f"${5 + int(export_type is not ExportType.DAILY) + len(group_keys)}"
|
||||
assert f"LIMIT {expected_limit_index}" in query.sql
|
||||
assert ("NULLIF(COALESCE(scoped.model, ''), '') AS model" in query.sql) is (
|
||||
export_type in (ExportType.DAILY_WITH_MODELS, ExportType.DAILY_WITH_KEYS_AND_MODELS)
|
||||
)
|
||||
assert query.params == (
|
||||
"2026-01-01",
|
||||
"2026-01-31",
|
||||
*((PTU_SENTINEL_API_KEY,) if export_type is not ExportType.DAILY else ()),
|
||||
"2026-01-12",
|
||||
"user-2",
|
||||
"group-3",
|
||||
*group_key_values,
|
||||
2,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("export_type", [ExportType.DAILY_WITH_KEYS, ExportType.DAILY_WITH_USERS])
|
||||
@pytest.mark.parametrize(
|
||||
"export_type",
|
||||
[ExportType.DAILY_WITH_KEYS, ExportType.DAILY_WITH_KEYS_AND_MODELS, ExportType.DAILY_WITH_USERS],
|
||||
)
|
||||
def test_export_uses_latest_deleted_key_metadata(export_type: ExportType) -> None:
|
||||
query = build_export_sql(_scope(entity_ids=None), export_type=export_type, after=None, batch_size=2)
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ describe("ExportTypeSelector", () => {
|
|||
expect(screen.getByText(/Day-by-day breakdown by team$/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Day-by-day breakdown by team and key/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Day-by-day by team and model/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Day-by-day by team, key and model/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display the correct entity type for different entities", () => {
|
||||
|
|
@ -29,6 +30,18 @@ describe("ExportTypeSelector", () => {
|
|||
expect(onChange).toHaveBeenCalledWith("daily_with_keys");
|
||||
});
|
||||
|
||||
it("should render and select the combined key and model option for users", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
renderWithProviders(<ExportTypeSelector value="daily" onChange={onChange} entityType="user" />);
|
||||
|
||||
const option = screen.getByRole("radio", { name: /Day-by-day by user, key and model/i });
|
||||
expect(screen.getByText("Daily metrics for each user, split by API key and model")).toBeInTheDocument();
|
||||
await user.click(option);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith("daily_with_keys_and_models");
|
||||
});
|
||||
|
||||
it("should have the correct radio checked", () => {
|
||||
renderWithProviders(<ExportTypeSelector value="daily_with_models" onChange={vi.fn()} entityType="team" />);
|
||||
expect(screen.getByRole("radio", { name: /Day-by-day by team and model/i })).toBeChecked();
|
||||
|
|
@ -55,6 +68,7 @@ describe("ExportTypeSelector", () => {
|
|||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("radio", { name: /Day-by-day breakdown by user and key/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("radio", { name: /Day-by-day by user and model/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("radio", { name: /Day-by-day by user, key and model/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should offer the per-user scope for tags", () => {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,11 @@ const ExportTypeSelector: React.FC<ExportTypeSelectorProps> = ({ value, onChange
|
|||
title: `Day-by-day by ${entityType} and model`,
|
||||
description: "Daily metrics split by model",
|
||||
},
|
||||
{
|
||||
value: "daily_with_keys_and_models",
|
||||
title: `Day-by-day by ${entityType}, key and model`,
|
||||
description: `Daily metrics for each ${entityType}, split by API key and model`,
|
||||
},
|
||||
{
|
||||
value: "daily_with_users",
|
||||
title: `Day-by-day breakdown by ${entityType} and user`,
|
||||
|
|
|
|||
2
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
2
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -32160,7 +32160,7 @@ export interface components {
|
|||
* ExportType
|
||||
* @enum {string}
|
||||
*/
|
||||
ExportType: "daily" | "daily_with_keys" | "daily_with_models" | "daily_with_users";
|
||||
ExportType: "daily" | "daily_with_keys" | "daily_with_models" | "daily_with_keys_and_models" | "daily_with_users";
|
||||
/**
|
||||
* FacetListResponse
|
||||
* @description The distinct values one column takes over a filtered query. `data` holds bare values, not entity rows.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue