| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554 |
- from datetime import date
- from types import SimpleNamespace
- from unittest.mock import AsyncMock, MagicMock
- import pytest
- from gpustack.api.exceptions import ForbiddenException
- from gpustack.routes.usage import (
- get_usage_breakdown,
- get_usage_meta,
- )
- from gpustack.schemas.users import User
- from gpustack.schemas.usage import (
- UsageBreakdownRequest,
- UsageFilterItem,
- UsageFilterRequest,
- UsageIdentity,
- UsageIdentityValue,
- UsageSummary,
- )
- def _mock_exec_result(rows):
- result = MagicMock()
- result.all.return_value = rows
- return result
- def _ctx_for(user):
- """Minimal TenantContext stub matching the route's read paths.
- Admins resolve with no current_principal_id (cross-Org "All" mode); regular
- users carry their default Org so they read their own usage rows. The
- route only touches ``is_platform_admin`` / ``current_principal_id`` /
- ``user``, so a MagicMock with those fields is enough."""
- ctx = MagicMock()
- ctx.user = user
- ctx.is_platform_admin = bool(getattr(user, "is_admin", False))
- ctx.current_principal_id = None if ctx.is_platform_admin else 1
- ctx.org_role = None
- return ctx
- @pytest.mark.asyncio
- async def test_get_usage_meta_returns_identity_filters_for_admin():
- session = MagicMock()
- session.exec = AsyncMock(
- side_effect=[
- _mock_exec_result(
- [
- SimpleNamespace(
- group_cluster_name="cluster-a",
- group_model_name="qwen3.5-9b",
- group_model_id=7,
- group_provider_id=None,
- group_provider_name=None,
- group_provider_type=None,
- ),
- SimpleNamespace(
- group_cluster_name=None,
- group_model_name="gpt-4o",
- group_model_id=None,
- group_provider_id=9,
- group_provider_name="openai-prod",
- group_provider_type="openai",
- ),
- SimpleNamespace(
- group_cluster_name="cluster-a",
- group_model_name="qwen3.5-9b",
- group_model_id=None,
- group_provider_id=None,
- group_provider_name=None,
- group_provider_type=None,
- ),
- ]
- ),
- _mock_exec_result(
- [
- SimpleNamespace(group_user_name="alice", group_user_id=12),
- SimpleNamespace(group_user_name="alice", group_user_id=None),
- ]
- ),
- _mock_exec_result(
- [
- SimpleNamespace(
- group_user_name="alice",
- group_api_key_name="test",
- group_access_key="abcd1234",
- group_api_key_is_custom=False,
- group_user_id=12,
- group_api_key_id=34,
- ),
- SimpleNamespace(
- group_user_name="alice",
- group_api_key_name="custom",
- group_access_key="hash1234",
- group_api_key_is_custom=True,
- group_user_id=12,
- group_api_key_id=35,
- ),
- ]
- ),
- ]
- )
- user = User(id=1, username="admin", hashed_password="x", is_admin=True)
- response = await get_usage_meta(session=session, user=user, ctx=_ctx_for(user))
- assert [item.key for item in response.group_bys] == [
- "date",
- "model",
- "user",
- "api_key",
- ]
- assert [item.key for item in response.metrics] == [
- "input_tokens",
- "output_tokens",
- "input_cached_tokens",
- "total_tokens",
- "api_requests",
- ]
- assert response.filters.models[0].label == "cluster-a / qwen3.5-9b"
- assert response.filters.models[0].deleted is False
- assert response.filters.models[0].identity.current.model_id == 7
- assert response.filters.models[1].label == "openai-prod / gpt-4o"
- assert response.filters.models[1].deleted is False
- assert response.filters.models[1].identity.current.provider_id == 9
- assert response.filters.models[1].identity.value.provider_name == "openai-prod"
- assert response.filters.models[1].identity.value.provider_type == "openai"
- assert response.filters.models[2].label == "cluster-a / qwen3.5-9b (Deleted)"
- assert response.filters.models[2].identity.current is None
- assert response.filters.users[1].label == "alice (Deleted)"
- assert response.filters.api_keys[0].label == "alice / test"
- assert response.filters.api_keys[0].identity.value.access_key == "abcd1234"
- assert response.filters.api_keys[0].identity.value.api_key_is_custom is False
- assert response.filters.api_keys[0].identity.current.api_key_id == 34
- assert response.filters.api_keys[1].label == "alice / custom"
- assert response.filters.api_keys[1].identity.value.access_key == "hash1234"
- assert response.filters.api_keys[1].identity.value.api_key_is_custom is True
- assert [item.key for item in response.granularities] == ["day", "week", "month"]
- api_key_statement = str(session.exec.call_args_list[2].args[0])
- assert "api_key_name IS NOT NULL" in api_key_statement
- assert "access_key IS NOT NULL" in api_key_statement
- @pytest.mark.asyncio
- async def test_get_usage_meta_hides_admin_only_options_for_regular_user():
- session = MagicMock()
- session.exec = AsyncMock(
- side_effect=[
- _mock_exec_result(
- [
- SimpleNamespace(
- group_cluster_name="cluster-a",
- group_model_name="qwen3.5-9b",
- group_model_id=7,
- )
- ]
- ),
- _mock_exec_result(
- [
- SimpleNamespace(
- group_user_name="alice",
- group_api_key_name="test",
- group_access_key="abcd1234",
- group_api_key_is_custom=False,
- group_user_id=12,
- group_api_key_id=34,
- )
- ]
- ),
- ]
- )
- user = User(id=2, username="alice", hashed_password="x", is_admin=False)
- response = await get_usage_meta(session=session, user=user, ctx=_ctx_for(user))
- assert [item.key for item in response.group_bys] == ["date", "model", "api_key"]
- assert response.filters.users == []
- assert response.filters.models[0].label == "cluster-a / qwen3.5-9b"
- @pytest.mark.asyncio
- async def test_get_usage_breakdown_returns_paginated_model_items():
- session = MagicMock()
- session.exec = AsyncMock(
- side_effect=[
- _mock_exec_result(
- [
- SimpleNamespace(
- input_tokens=400,
- output_tokens=140,
- input_cached_tokens=100,
- total_tokens=540,
- api_requests=4,
- models_called=2,
- ),
- ]
- ),
- _mock_exec_result([2]),
- _mock_exec_result(
- [
- SimpleNamespace(
- group_cluster_name="cluster-a",
- group_model_name="qwen3.5-9b",
- group_model_id=7,
- group_provider_id=None,
- group_provider_name=None,
- group_provider_type=None,
- input_tokens=300,
- output_tokens=120,
- input_cached_tokens=90,
- total_tokens=420,
- api_requests=3,
- models_called=1,
- api_keys_used=2,
- last_active=date(2026, 4, 2),
- ),
- SimpleNamespace(
- group_cluster_name="cluster-b",
- group_model_name="deepseek-v3",
- group_model_id=8,
- group_provider_id=None,
- group_provider_name=None,
- group_provider_type=None,
- input_tokens=100,
- output_tokens=20,
- input_cached_tokens=10,
- total_tokens=120,
- api_requests=1,
- models_called=1,
- api_keys_used=1,
- last_active=date(2026, 4, 1),
- ),
- ]
- ),
- ]
- )
- user = User(id=1, username="admin", hashed_password="x", is_admin=True)
- request = UsageBreakdownRequest(
- start_date=date(2026, 4, 1),
- end_date=date(2026, 4, 2),
- group_by=["model"],
- sort_by="-total_tokens",
- page=1,
- perPage=20,
- )
- response = await get_usage_breakdown(
- session=session, user=user, ctx=_ctx_for(user), request=request
- )
- assert response.summary.input_tokens == 400
- assert response.summary.output_tokens == 140
- assert response.summary.input_cached_tokens == 100
- assert response.summary.total_tokens == 540
- assert response.summary.api_requests == 4
- assert response.summary.models_called == 2
- assert response.group_by == ["model"]
- assert response.pagination.page == 1
- assert response.pagination.perPage == 20
- assert response.pagination.total == 2
- assert response.pagination.totalPage == 1
- assert len(response.items) == 2
- item = response.items[0]
- assert item.model.identity.value.model_name == "qwen3.5-9b"
- assert item.model.identity.current.model_id == 7
- assert item.model.label == "cluster-a / qwen3.5-9b"
- assert item.input_cached_tokens == 90
- assert item.avg_tokens_per_request == 140
- assert item.last_active == date(2026, 4, 2)
- @pytest.mark.asyncio
- async def test_get_usage_breakdown_returns_multidimensional_export_rows_with_no_api_key():
- session = MagicMock()
- session.get_bind.return_value = SimpleNamespace(
- dialect=SimpleNamespace(name="postgresql")
- )
- session.exec = AsyncMock(
- side_effect=[
- _mock_exec_result(
- [
- SimpleNamespace(
- input_tokens=400,
- output_tokens=160,
- total_tokens=560,
- api_requests=7,
- models_called=2,
- ),
- ]
- ),
- _mock_exec_result([2]),
- _mock_exec_result(
- [
- SimpleNamespace(
- group_date=date(2026, 3, 30),
- group_user_name="alice",
- group_user_id=12,
- group_api_key_name="test",
- group_access_key="abcd1234",
- group_api_key_is_custom=False,
- group_api_key_id=34,
- group_cluster_name="cluster-a",
- group_model_name="gpt-4o",
- group_model_id=7,
- group_provider_id=3,
- group_provider_name="openai-prod",
- group_provider_type="openai",
- input_tokens=300,
- output_tokens=120,
- total_tokens=420,
- api_requests=5,
- models_called=1,
- api_keys_used=1,
- last_active=date(2026, 4, 1),
- ),
- SimpleNamespace(
- group_date=date(2026, 3, 30),
- group_user_name="alice",
- group_user_id=12,
- group_api_key_name=None,
- group_access_key=None,
- group_api_key_is_custom=None,
- group_api_key_id=None,
- group_cluster_name="cluster-a",
- group_model_name="qwen3.5-9b",
- group_model_id=8,
- group_provider_id=None,
- group_provider_name=None,
- group_provider_type=None,
- input_tokens=100,
- output_tokens=40,
- total_tokens=140,
- api_requests=2,
- models_called=1,
- api_keys_used=0,
- last_active=date(2026, 4, 2),
- ),
- ]
- ),
- ]
- )
- user = User(id=1, username="admin", hashed_password="x", is_admin=True)
- request = UsageBreakdownRequest(
- start_date=date(2026, 4, 1),
- end_date=date(2026, 4, 2),
- group_by=["date", "user", "api_key", "model"],
- granularity="week",
- sort_by="date",
- page=1,
- perPage=20,
- )
- response = await get_usage_breakdown(
- session=session, user=user, ctx=_ctx_for(user), request=request
- )
- assert response.group_by == ["date", "user", "api_key", "model"]
- assert response.granularity == "week"
- assert response.pagination.total == 2
- assert response.items[0].date.value == date(2026, 3, 30)
- assert response.items[0].user.label == "alice"
- assert response.items[0].api_key.label == "alice / test"
- assert response.items[0].model.label == "cluster-a / openai-prod / gpt-4o"
- assert response.items[1].date.value == date(2026, 3, 30)
- assert response.items[1].api_key.identity is None
- assert response.items[1].api_key.label == "-"
- count_sql = str(session.exec.call_args_list[1].args[0])
- items_sql = str(session.exec.call_args_list[2].args[0])
- assert "date_trunc" in count_sql
- assert "LIMIT" in items_sql
- assert "api_key_name IS NOT NULL" not in count_sql
- assert "access_key IS NOT NULL" not in count_sql
- @pytest.mark.asyncio
- async def test_get_usage_breakdown_ignores_incomplete_api_key_identity_groups():
- session = MagicMock()
- session.exec = AsyncMock(
- side_effect=[
- _mock_exec_result(
- [
- SimpleNamespace(
- input_tokens=0,
- output_tokens=0,
- input_cached_tokens=0,
- total_tokens=0,
- api_requests=0,
- models_called=0,
- ),
- ]
- ),
- _mock_exec_result([0]),
- _mock_exec_result([]),
- ]
- )
- user = User(id=1, username="admin", hashed_password="x", is_admin=True)
- request = UsageBreakdownRequest(
- start_date=date(2026, 4, 1),
- end_date=date(2026, 4, 2),
- group_by=["api_key"],
- )
- response = await get_usage_breakdown(
- session=session, user=user, ctx=_ctx_for(user), request=request
- )
- assert response.summary == UsageSummary()
- assert response.items == []
- executed_sql = str(session.exec.call_args_list[0].args[0])
- assert "api_key_name IS NOT NULL" in executed_sql
- assert "access_key IS NOT NULL" in executed_sql
- @pytest.mark.asyncio
- async def test_get_usage_breakdown_formats_month_date_label_as_year_month():
- session = MagicMock()
- session.exec = AsyncMock(
- side_effect=[
- _mock_exec_result(
- [
- SimpleNamespace(
- input_tokens=100,
- output_tokens=40,
- input_cached_tokens=10,
- total_tokens=150,
- api_requests=2,
- models_called=1,
- ),
- ]
- ),
- _mock_exec_result([1]),
- _mock_exec_result(
- [
- SimpleNamespace(
- group_date=date(2026, 4, 1),
- input_tokens=100,
- output_tokens=40,
- input_cached_tokens=10,
- total_tokens=150,
- api_requests=2,
- models_called=1,
- api_keys_used=1,
- last_active=date(2026, 4, 20),
- ),
- ]
- ),
- ]
- )
- user = User(id=1, username="admin", hashed_password="x", is_admin=True)
- request = UsageBreakdownRequest(
- start_date=date(2026, 4, 1),
- end_date=date(2026, 4, 30),
- group_by=["date"],
- granularity="month",
- )
- response = await get_usage_breakdown(
- session=session, user=user, ctx=_ctx_for(user), request=request
- )
- assert response.granularity == "month"
- assert response.items[0].date.value == date(2026, 4, 1)
- assert response.items[0].date.label == "2026-04"
- @pytest.mark.asyncio
- async def test_get_usage_breakdown_filters_deleted_api_key_by_value_and_current():
- session = MagicMock()
- session.exec = AsyncMock(
- side_effect=[
- _mock_exec_result([SimpleNamespace()]),
- _mock_exec_result([0]),
- _mock_exec_result([]),
- ]
- )
- user = User(id=1, username="admin", hashed_password="x", is_admin=True)
- request = UsageBreakdownRequest(
- start_date=date(2026, 4, 1),
- end_date=date(2026, 4, 2),
- group_by=["api_key"],
- filters=UsageFilterRequest(
- api_keys=[
- UsageFilterItem(
- identity=UsageIdentity(
- value=UsageIdentityValue(
- user_name="alice",
- api_key_name="test",
- access_key="abcd1234",
- api_key_is_custom=False,
- ),
- current=None,
- )
- )
- ]
- ),
- )
- await get_usage_breakdown(
- session=session, user=user, ctx=_ctx_for(user), request=request
- )
- executed_sql = str(session.exec.call_args_list[0].args[0])
- assert "api_key_id IS NULL" in executed_sql
- assert "user_name" in executed_sql
- assert "api_key_name" in executed_sql
- assert "access_key" in executed_sql
- assert "api_key_is_custom" in executed_sql
- assert "api_key_name IS NOT NULL" in executed_sql
- assert "access_key IS NOT NULL" in executed_sql
- @pytest.mark.asyncio
- async def test_get_usage_breakdown_defaults_regular_user_to_self_scope():
- session = MagicMock()
- session.exec = AsyncMock(
- side_effect=[
- _mock_exec_result([SimpleNamespace()]),
- _mock_exec_result([0]),
- _mock_exec_result([]),
- ]
- )
- user = User(id=2, username="alice", hashed_password="x", is_admin=False)
- request = UsageBreakdownRequest(
- start_date=date(2026, 4, 1),
- end_date=date(2026, 4, 2),
- group_by=["model"],
- )
- await get_usage_breakdown(
- session=session, user=user, ctx=_ctx_for(user), request=request
- )
- executed_sql = str(session.exec.call_args_list[0].args[0])
- assert "model_usages.user_id =" in executed_sql
- @pytest.mark.asyncio
- async def test_get_usage_breakdown_rejects_regular_user_user_group():
- session = MagicMock()
- user = User(id=2, username="alice", hashed_password="x", is_admin=False)
- request = UsageBreakdownRequest(
- start_date=date(2026, 4, 1),
- end_date=date(2026, 4, 2),
- group_by=["user"],
- )
- with pytest.raises(ForbiddenException):
- await get_usage_breakdown(
- session=session, user=user, ctx=_ctx_for(user), request=request
- )
|