test_usage.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554
  1. from datetime import date
  2. from types import SimpleNamespace
  3. from unittest.mock import AsyncMock, MagicMock
  4. import pytest
  5. from gpustack.api.exceptions import ForbiddenException
  6. from gpustack.routes.usage import (
  7. get_usage_breakdown,
  8. get_usage_meta,
  9. )
  10. from gpustack.schemas.users import User
  11. from gpustack.schemas.usage import (
  12. UsageBreakdownRequest,
  13. UsageFilterItem,
  14. UsageFilterRequest,
  15. UsageIdentity,
  16. UsageIdentityValue,
  17. UsageSummary,
  18. )
  19. def _mock_exec_result(rows):
  20. result = MagicMock()
  21. result.all.return_value = rows
  22. return result
  23. def _ctx_for(user):
  24. """Minimal TenantContext stub matching the route's read paths.
  25. Admins resolve with no current_principal_id (cross-Org "All" mode); regular
  26. users carry their default Org so they read their own usage rows. The
  27. route only touches ``is_platform_admin`` / ``current_principal_id`` /
  28. ``user``, so a MagicMock with those fields is enough."""
  29. ctx = MagicMock()
  30. ctx.user = user
  31. ctx.is_platform_admin = bool(getattr(user, "is_admin", False))
  32. ctx.current_principal_id = None if ctx.is_platform_admin else 1
  33. ctx.org_role = None
  34. return ctx
  35. @pytest.mark.asyncio
  36. async def test_get_usage_meta_returns_identity_filters_for_admin():
  37. session = MagicMock()
  38. session.exec = AsyncMock(
  39. side_effect=[
  40. _mock_exec_result(
  41. [
  42. SimpleNamespace(
  43. group_cluster_name="cluster-a",
  44. group_model_name="qwen3.5-9b",
  45. group_model_id=7,
  46. group_provider_id=None,
  47. group_provider_name=None,
  48. group_provider_type=None,
  49. ),
  50. SimpleNamespace(
  51. group_cluster_name=None,
  52. group_model_name="gpt-4o",
  53. group_model_id=None,
  54. group_provider_id=9,
  55. group_provider_name="openai-prod",
  56. group_provider_type="openai",
  57. ),
  58. SimpleNamespace(
  59. group_cluster_name="cluster-a",
  60. group_model_name="qwen3.5-9b",
  61. group_model_id=None,
  62. group_provider_id=None,
  63. group_provider_name=None,
  64. group_provider_type=None,
  65. ),
  66. ]
  67. ),
  68. _mock_exec_result(
  69. [
  70. SimpleNamespace(group_user_name="alice", group_user_id=12),
  71. SimpleNamespace(group_user_name="alice", group_user_id=None),
  72. ]
  73. ),
  74. _mock_exec_result(
  75. [
  76. SimpleNamespace(
  77. group_user_name="alice",
  78. group_api_key_name="test",
  79. group_access_key="abcd1234",
  80. group_api_key_is_custom=False,
  81. group_user_id=12,
  82. group_api_key_id=34,
  83. ),
  84. SimpleNamespace(
  85. group_user_name="alice",
  86. group_api_key_name="custom",
  87. group_access_key="hash1234",
  88. group_api_key_is_custom=True,
  89. group_user_id=12,
  90. group_api_key_id=35,
  91. ),
  92. ]
  93. ),
  94. ]
  95. )
  96. user = User(id=1, username="admin", hashed_password="x", is_admin=True)
  97. response = await get_usage_meta(session=session, user=user, ctx=_ctx_for(user))
  98. assert [item.key for item in response.group_bys] == [
  99. "date",
  100. "model",
  101. "user",
  102. "api_key",
  103. ]
  104. assert [item.key for item in response.metrics] == [
  105. "input_tokens",
  106. "output_tokens",
  107. "input_cached_tokens",
  108. "total_tokens",
  109. "api_requests",
  110. ]
  111. assert response.filters.models[0].label == "cluster-a / qwen3.5-9b"
  112. assert response.filters.models[0].deleted is False
  113. assert response.filters.models[0].identity.current.model_id == 7
  114. assert response.filters.models[1].label == "openai-prod / gpt-4o"
  115. assert response.filters.models[1].deleted is False
  116. assert response.filters.models[1].identity.current.provider_id == 9
  117. assert response.filters.models[1].identity.value.provider_name == "openai-prod"
  118. assert response.filters.models[1].identity.value.provider_type == "openai"
  119. assert response.filters.models[2].label == "cluster-a / qwen3.5-9b (Deleted)"
  120. assert response.filters.models[2].identity.current is None
  121. assert response.filters.users[1].label == "alice (Deleted)"
  122. assert response.filters.api_keys[0].label == "alice / test"
  123. assert response.filters.api_keys[0].identity.value.access_key == "abcd1234"
  124. assert response.filters.api_keys[0].identity.value.api_key_is_custom is False
  125. assert response.filters.api_keys[0].identity.current.api_key_id == 34
  126. assert response.filters.api_keys[1].label == "alice / custom"
  127. assert response.filters.api_keys[1].identity.value.access_key == "hash1234"
  128. assert response.filters.api_keys[1].identity.value.api_key_is_custom is True
  129. assert [item.key for item in response.granularities] == ["day", "week", "month"]
  130. api_key_statement = str(session.exec.call_args_list[2].args[0])
  131. assert "api_key_name IS NOT NULL" in api_key_statement
  132. assert "access_key IS NOT NULL" in api_key_statement
  133. @pytest.mark.asyncio
  134. async def test_get_usage_meta_hides_admin_only_options_for_regular_user():
  135. session = MagicMock()
  136. session.exec = AsyncMock(
  137. side_effect=[
  138. _mock_exec_result(
  139. [
  140. SimpleNamespace(
  141. group_cluster_name="cluster-a",
  142. group_model_name="qwen3.5-9b",
  143. group_model_id=7,
  144. )
  145. ]
  146. ),
  147. _mock_exec_result(
  148. [
  149. SimpleNamespace(
  150. group_user_name="alice",
  151. group_api_key_name="test",
  152. group_access_key="abcd1234",
  153. group_api_key_is_custom=False,
  154. group_user_id=12,
  155. group_api_key_id=34,
  156. )
  157. ]
  158. ),
  159. ]
  160. )
  161. user = User(id=2, username="alice", hashed_password="x", is_admin=False)
  162. response = await get_usage_meta(session=session, user=user, ctx=_ctx_for(user))
  163. assert [item.key for item in response.group_bys] == ["date", "model", "api_key"]
  164. assert response.filters.users == []
  165. assert response.filters.models[0].label == "cluster-a / qwen3.5-9b"
  166. @pytest.mark.asyncio
  167. async def test_get_usage_breakdown_returns_paginated_model_items():
  168. session = MagicMock()
  169. session.exec = AsyncMock(
  170. side_effect=[
  171. _mock_exec_result(
  172. [
  173. SimpleNamespace(
  174. input_tokens=400,
  175. output_tokens=140,
  176. input_cached_tokens=100,
  177. total_tokens=540,
  178. api_requests=4,
  179. models_called=2,
  180. ),
  181. ]
  182. ),
  183. _mock_exec_result([2]),
  184. _mock_exec_result(
  185. [
  186. SimpleNamespace(
  187. group_cluster_name="cluster-a",
  188. group_model_name="qwen3.5-9b",
  189. group_model_id=7,
  190. group_provider_id=None,
  191. group_provider_name=None,
  192. group_provider_type=None,
  193. input_tokens=300,
  194. output_tokens=120,
  195. input_cached_tokens=90,
  196. total_tokens=420,
  197. api_requests=3,
  198. models_called=1,
  199. api_keys_used=2,
  200. last_active=date(2026, 4, 2),
  201. ),
  202. SimpleNamespace(
  203. group_cluster_name="cluster-b",
  204. group_model_name="deepseek-v3",
  205. group_model_id=8,
  206. group_provider_id=None,
  207. group_provider_name=None,
  208. group_provider_type=None,
  209. input_tokens=100,
  210. output_tokens=20,
  211. input_cached_tokens=10,
  212. total_tokens=120,
  213. api_requests=1,
  214. models_called=1,
  215. api_keys_used=1,
  216. last_active=date(2026, 4, 1),
  217. ),
  218. ]
  219. ),
  220. ]
  221. )
  222. user = User(id=1, username="admin", hashed_password="x", is_admin=True)
  223. request = UsageBreakdownRequest(
  224. start_date=date(2026, 4, 1),
  225. end_date=date(2026, 4, 2),
  226. group_by=["model"],
  227. sort_by="-total_tokens",
  228. page=1,
  229. perPage=20,
  230. )
  231. response = await get_usage_breakdown(
  232. session=session, user=user, ctx=_ctx_for(user), request=request
  233. )
  234. assert response.summary.input_tokens == 400
  235. assert response.summary.output_tokens == 140
  236. assert response.summary.input_cached_tokens == 100
  237. assert response.summary.total_tokens == 540
  238. assert response.summary.api_requests == 4
  239. assert response.summary.models_called == 2
  240. assert response.group_by == ["model"]
  241. assert response.pagination.page == 1
  242. assert response.pagination.perPage == 20
  243. assert response.pagination.total == 2
  244. assert response.pagination.totalPage == 1
  245. assert len(response.items) == 2
  246. item = response.items[0]
  247. assert item.model.identity.value.model_name == "qwen3.5-9b"
  248. assert item.model.identity.current.model_id == 7
  249. assert item.model.label == "cluster-a / qwen3.5-9b"
  250. assert item.input_cached_tokens == 90
  251. assert item.avg_tokens_per_request == 140
  252. assert item.last_active == date(2026, 4, 2)
  253. @pytest.mark.asyncio
  254. async def test_get_usage_breakdown_returns_multidimensional_export_rows_with_no_api_key():
  255. session = MagicMock()
  256. session.get_bind.return_value = SimpleNamespace(
  257. dialect=SimpleNamespace(name="postgresql")
  258. )
  259. session.exec = AsyncMock(
  260. side_effect=[
  261. _mock_exec_result(
  262. [
  263. SimpleNamespace(
  264. input_tokens=400,
  265. output_tokens=160,
  266. total_tokens=560,
  267. api_requests=7,
  268. models_called=2,
  269. ),
  270. ]
  271. ),
  272. _mock_exec_result([2]),
  273. _mock_exec_result(
  274. [
  275. SimpleNamespace(
  276. group_date=date(2026, 3, 30),
  277. group_user_name="alice",
  278. group_user_id=12,
  279. group_api_key_name="test",
  280. group_access_key="abcd1234",
  281. group_api_key_is_custom=False,
  282. group_api_key_id=34,
  283. group_cluster_name="cluster-a",
  284. group_model_name="gpt-4o",
  285. group_model_id=7,
  286. group_provider_id=3,
  287. group_provider_name="openai-prod",
  288. group_provider_type="openai",
  289. input_tokens=300,
  290. output_tokens=120,
  291. total_tokens=420,
  292. api_requests=5,
  293. models_called=1,
  294. api_keys_used=1,
  295. last_active=date(2026, 4, 1),
  296. ),
  297. SimpleNamespace(
  298. group_date=date(2026, 3, 30),
  299. group_user_name="alice",
  300. group_user_id=12,
  301. group_api_key_name=None,
  302. group_access_key=None,
  303. group_api_key_is_custom=None,
  304. group_api_key_id=None,
  305. group_cluster_name="cluster-a",
  306. group_model_name="qwen3.5-9b",
  307. group_model_id=8,
  308. group_provider_id=None,
  309. group_provider_name=None,
  310. group_provider_type=None,
  311. input_tokens=100,
  312. output_tokens=40,
  313. total_tokens=140,
  314. api_requests=2,
  315. models_called=1,
  316. api_keys_used=0,
  317. last_active=date(2026, 4, 2),
  318. ),
  319. ]
  320. ),
  321. ]
  322. )
  323. user = User(id=1, username="admin", hashed_password="x", is_admin=True)
  324. request = UsageBreakdownRequest(
  325. start_date=date(2026, 4, 1),
  326. end_date=date(2026, 4, 2),
  327. group_by=["date", "user", "api_key", "model"],
  328. granularity="week",
  329. sort_by="date",
  330. page=1,
  331. perPage=20,
  332. )
  333. response = await get_usage_breakdown(
  334. session=session, user=user, ctx=_ctx_for(user), request=request
  335. )
  336. assert response.group_by == ["date", "user", "api_key", "model"]
  337. assert response.granularity == "week"
  338. assert response.pagination.total == 2
  339. assert response.items[0].date.value == date(2026, 3, 30)
  340. assert response.items[0].user.label == "alice"
  341. assert response.items[0].api_key.label == "alice / test"
  342. assert response.items[0].model.label == "cluster-a / openai-prod / gpt-4o"
  343. assert response.items[1].date.value == date(2026, 3, 30)
  344. assert response.items[1].api_key.identity is None
  345. assert response.items[1].api_key.label == "-"
  346. count_sql = str(session.exec.call_args_list[1].args[0])
  347. items_sql = str(session.exec.call_args_list[2].args[0])
  348. assert "date_trunc" in count_sql
  349. assert "LIMIT" in items_sql
  350. assert "api_key_name IS NOT NULL" not in count_sql
  351. assert "access_key IS NOT NULL" not in count_sql
  352. @pytest.mark.asyncio
  353. async def test_get_usage_breakdown_ignores_incomplete_api_key_identity_groups():
  354. session = MagicMock()
  355. session.exec = AsyncMock(
  356. side_effect=[
  357. _mock_exec_result(
  358. [
  359. SimpleNamespace(
  360. input_tokens=0,
  361. output_tokens=0,
  362. input_cached_tokens=0,
  363. total_tokens=0,
  364. api_requests=0,
  365. models_called=0,
  366. ),
  367. ]
  368. ),
  369. _mock_exec_result([0]),
  370. _mock_exec_result([]),
  371. ]
  372. )
  373. user = User(id=1, username="admin", hashed_password="x", is_admin=True)
  374. request = UsageBreakdownRequest(
  375. start_date=date(2026, 4, 1),
  376. end_date=date(2026, 4, 2),
  377. group_by=["api_key"],
  378. )
  379. response = await get_usage_breakdown(
  380. session=session, user=user, ctx=_ctx_for(user), request=request
  381. )
  382. assert response.summary == UsageSummary()
  383. assert response.items == []
  384. executed_sql = str(session.exec.call_args_list[0].args[0])
  385. assert "api_key_name IS NOT NULL" in executed_sql
  386. assert "access_key IS NOT NULL" in executed_sql
  387. @pytest.mark.asyncio
  388. async def test_get_usage_breakdown_formats_month_date_label_as_year_month():
  389. session = MagicMock()
  390. session.exec = AsyncMock(
  391. side_effect=[
  392. _mock_exec_result(
  393. [
  394. SimpleNamespace(
  395. input_tokens=100,
  396. output_tokens=40,
  397. input_cached_tokens=10,
  398. total_tokens=150,
  399. api_requests=2,
  400. models_called=1,
  401. ),
  402. ]
  403. ),
  404. _mock_exec_result([1]),
  405. _mock_exec_result(
  406. [
  407. SimpleNamespace(
  408. group_date=date(2026, 4, 1),
  409. input_tokens=100,
  410. output_tokens=40,
  411. input_cached_tokens=10,
  412. total_tokens=150,
  413. api_requests=2,
  414. models_called=1,
  415. api_keys_used=1,
  416. last_active=date(2026, 4, 20),
  417. ),
  418. ]
  419. ),
  420. ]
  421. )
  422. user = User(id=1, username="admin", hashed_password="x", is_admin=True)
  423. request = UsageBreakdownRequest(
  424. start_date=date(2026, 4, 1),
  425. end_date=date(2026, 4, 30),
  426. group_by=["date"],
  427. granularity="month",
  428. )
  429. response = await get_usage_breakdown(
  430. session=session, user=user, ctx=_ctx_for(user), request=request
  431. )
  432. assert response.granularity == "month"
  433. assert response.items[0].date.value == date(2026, 4, 1)
  434. assert response.items[0].date.label == "2026-04"
  435. @pytest.mark.asyncio
  436. async def test_get_usage_breakdown_filters_deleted_api_key_by_value_and_current():
  437. session = MagicMock()
  438. session.exec = AsyncMock(
  439. side_effect=[
  440. _mock_exec_result([SimpleNamespace()]),
  441. _mock_exec_result([0]),
  442. _mock_exec_result([]),
  443. ]
  444. )
  445. user = User(id=1, username="admin", hashed_password="x", is_admin=True)
  446. request = UsageBreakdownRequest(
  447. start_date=date(2026, 4, 1),
  448. end_date=date(2026, 4, 2),
  449. group_by=["api_key"],
  450. filters=UsageFilterRequest(
  451. api_keys=[
  452. UsageFilterItem(
  453. identity=UsageIdentity(
  454. value=UsageIdentityValue(
  455. user_name="alice",
  456. api_key_name="test",
  457. access_key="abcd1234",
  458. api_key_is_custom=False,
  459. ),
  460. current=None,
  461. )
  462. )
  463. ]
  464. ),
  465. )
  466. await get_usage_breakdown(
  467. session=session, user=user, ctx=_ctx_for(user), request=request
  468. )
  469. executed_sql = str(session.exec.call_args_list[0].args[0])
  470. assert "api_key_id IS NULL" in executed_sql
  471. assert "user_name" in executed_sql
  472. assert "api_key_name" in executed_sql
  473. assert "access_key" in executed_sql
  474. assert "api_key_is_custom" in executed_sql
  475. assert "api_key_name IS NOT NULL" in executed_sql
  476. assert "access_key IS NOT NULL" in executed_sql
  477. @pytest.mark.asyncio
  478. async def test_get_usage_breakdown_defaults_regular_user_to_self_scope():
  479. session = MagicMock()
  480. session.exec = AsyncMock(
  481. side_effect=[
  482. _mock_exec_result([SimpleNamespace()]),
  483. _mock_exec_result([0]),
  484. _mock_exec_result([]),
  485. ]
  486. )
  487. user = User(id=2, username="alice", hashed_password="x", is_admin=False)
  488. request = UsageBreakdownRequest(
  489. start_date=date(2026, 4, 1),
  490. end_date=date(2026, 4, 2),
  491. group_by=["model"],
  492. )
  493. await get_usage_breakdown(
  494. session=session, user=user, ctx=_ctx_for(user), request=request
  495. )
  496. executed_sql = str(session.exec.call_args_list[0].args[0])
  497. assert "model_usages.user_id =" in executed_sql
  498. @pytest.mark.asyncio
  499. async def test_get_usage_breakdown_rejects_regular_user_user_group():
  500. session = MagicMock()
  501. user = User(id=2, username="alice", hashed_password="x", is_admin=False)
  502. request = UsageBreakdownRequest(
  503. start_date=date(2026, 4, 1),
  504. end_date=date(2026, 4, 2),
  505. group_by=["user"],
  506. )
  507. with pytest.raises(ForbiddenException):
  508. await get_usage_breakdown(
  509. session=session, user=user, ctx=_ctx_for(user), request=request
  510. )