usage.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756
  1. from datetime import date, datetime
  2. from math import ceil
  3. from typing import Any, Dict, List, Optional
  4. from fastapi import APIRouter
  5. from sqlalchemy import Date, Select, String, and_, asc, cast, desc, literal
  6. from sqlmodel import func, or_, select
  7. from gpustack.api.exceptions import ForbiddenException, InvalidException
  8. from gpustack.schemas.model_usage import ModelUsage
  9. from gpustack.schemas.users import User
  10. from gpustack.schemas.principals import OrgRole
  11. from gpustack.schemas.usage import (
  12. USAGE_GRANULARITY_MONTH,
  13. USAGE_GRANULARITY_DAY,
  14. USAGE_GRANULARITY_WEEK,
  15. USAGE_GROUP_BY_API_KEY,
  16. USAGE_GROUP_BY_DATE,
  17. USAGE_GROUP_BY_MODEL,
  18. USAGE_GROUP_BY_USER,
  19. USAGE_SCOPE_ALL,
  20. USAGE_SCOPE_SELF,
  21. USAGE_METRIC_API_KEYS_USED,
  22. USAGE_METRIC_API_REQUESTS,
  23. USAGE_METRIC_AVG_TOKENS_PER_REQUEST,
  24. USAGE_METRIC_INPUT_CACHED_TOKENS,
  25. USAGE_METRIC_DATE,
  26. USAGE_METRIC_INPUT_TOKENS,
  27. USAGE_METRIC_LAST_ACTIVE,
  28. USAGE_METRIC_MODELS_CALLED,
  29. USAGE_METRIC_OUTPUT_TOKENS,
  30. USAGE_METRIC_TOTAL_TOKENS,
  31. USAGE_SORT_DESC,
  32. UsageBreakdownDateDimension,
  33. UsageBreakdownDimension,
  34. UsageBreakdownItem,
  35. UsageBreakdownRequest,
  36. UsageBreakdownResponse,
  37. UsageFilterItem,
  38. UsageFilterOption,
  39. UsageFilters,
  40. UsageIdentity,
  41. UsageIdentityCurrent,
  42. UsageIdentityValue,
  43. UsageMetaResponse,
  44. UsageOption,
  45. UsageSummary,
  46. )
  47. from gpustack.schemas.common import Pagination
  48. from gpustack.server.deps import CurrentUserDep, SessionDep, TenantContextDep
  49. from gpustack.utils.usage_snapshots import (
  50. format_usage_api_key_label,
  51. format_usage_date_label,
  52. format_usage_model_label,
  53. format_usage_user_label,
  54. )
  55. router = APIRouter()
  56. METRIC_OPTIONS = [
  57. UsageOption(key=USAGE_METRIC_INPUT_TOKENS, label="Input Tokens"),
  58. UsageOption(key=USAGE_METRIC_OUTPUT_TOKENS, label="Output Tokens"),
  59. UsageOption(key=USAGE_METRIC_INPUT_CACHED_TOKENS, label="Input Cached Tokens"),
  60. UsageOption(key=USAGE_METRIC_TOTAL_TOKENS, label="Total Tokens"),
  61. UsageOption(key=USAGE_METRIC_API_REQUESTS, label="API Requests"),
  62. ]
  63. GRANULARITY_OPTIONS = [
  64. UsageOption(key=USAGE_GRANULARITY_DAY, label="Day"),
  65. UsageOption(key=USAGE_GRANULARITY_WEEK, label="Week"),
  66. UsageOption(key=USAGE_GRANULARITY_MONTH, label="Month"),
  67. ]
  68. SELF_GROUP_BY_OPTIONS = [
  69. UsageOption(key=USAGE_GROUP_BY_DATE, label="Date"),
  70. UsageOption(key=USAGE_GROUP_BY_MODEL, label="Model"),
  71. UsageOption(key=USAGE_GROUP_BY_API_KEY, label="API Key"),
  72. ]
  73. ADMIN_GROUP_BY_OPTIONS = [
  74. UsageOption(key=USAGE_GROUP_BY_DATE, label="Date"),
  75. UsageOption(key=USAGE_GROUP_BY_MODEL, label="Model"),
  76. UsageOption(key=USAGE_GROUP_BY_USER, label="User"),
  77. UsageOption(key=USAGE_GROUP_BY_API_KEY, label="API Key"),
  78. ]
  79. def _null_safe_column_filter(column, value: Any):
  80. if value is None:
  81. return column.is_(None)
  82. return column == value
  83. def _model_key_expression():
  84. return (
  85. func.coalesce(ModelUsage.cluster_name + literal("/"), literal(""))
  86. + ModelUsage.model_name
  87. )
  88. def _model_identity_expression():
  89. """Distinct identity for counting models called.
  90. Include current IDs and snapshot fields so direct deployments, provider
  91. models, and deleted historical records with the same model name remain
  92. separate identities.
  93. """
  94. return (
  95. func.coalesce(cast(ModelUsage.model_id, String), literal("deleted"))
  96. + literal("|")
  97. + func.coalesce(cast(ModelUsage.provider_id, String), literal("no-provider"))
  98. + literal("|")
  99. + func.coalesce(ModelUsage.provider_name, literal(""))
  100. + literal("|")
  101. + func.coalesce(ModelUsage.provider_type, literal(""))
  102. + literal("|")
  103. + _model_key_expression()
  104. )
  105. def _metric_columns() -> Dict[str, Any]:
  106. model_identity = _model_identity_expression()
  107. return {
  108. USAGE_METRIC_INPUT_TOKENS: func.coalesce(
  109. func.sum(ModelUsage.prompt_token_count), 0
  110. ),
  111. USAGE_METRIC_OUTPUT_TOKENS: func.coalesce(
  112. func.sum(ModelUsage.completion_token_count), 0
  113. ),
  114. USAGE_METRIC_INPUT_CACHED_TOKENS: func.coalesce(
  115. func.sum(ModelUsage.prompt_cached_token_count), 0
  116. ),
  117. USAGE_METRIC_TOTAL_TOKENS: func.coalesce(
  118. func.sum(ModelUsage.prompt_token_count + ModelUsage.completion_token_count),
  119. 0,
  120. ),
  121. USAGE_METRIC_API_REQUESTS: func.coalesce(func.sum(ModelUsage.request_count), 0),
  122. USAGE_METRIC_MODELS_CALLED: func.count(func.distinct(model_identity)),
  123. }
  124. def _date_bucket_expression(session, granularity: str):
  125. if granularity == USAGE_GRANULARITY_DAY:
  126. return ModelUsage.date
  127. dialect = session.get_bind().dialect.name
  128. if granularity == USAGE_GRANULARITY_WEEK:
  129. if dialect == "postgresql":
  130. return cast(func.date_trunc("week", ModelUsage.date), Date)
  131. if dialect == "mysql":
  132. return func.subdate(ModelUsage.date, func.weekday(ModelUsage.date))
  133. if granularity == USAGE_GRANULARITY_MONTH:
  134. if dialect == "postgresql":
  135. return cast(func.date_trunc("month", ModelUsage.date), Date)
  136. if dialect == "mysql":
  137. return func.str_to_date(
  138. func.date_format(ModelUsage.date, "%Y-%m-01"), "%Y-%m-%d"
  139. )
  140. return ModelUsage.date
  141. def _group_columns(group_by: str, *, date_bucket_expr=None):
  142. if group_by == USAGE_GROUP_BY_DATE:
  143. if date_bucket_expr is None:
  144. date_bucket_expr = ModelUsage.date
  145. return [date_bucket_expr.label("group_date")], [date_bucket_expr]
  146. if group_by == USAGE_GROUP_BY_MODEL:
  147. return [
  148. ModelUsage.cluster_name.label("group_cluster_name"),
  149. ModelUsage.model_name.label("group_model_name"),
  150. ModelUsage.model_id.label("group_model_id"),
  151. ModelUsage.provider_name.label("group_provider_name"),
  152. ModelUsage.provider_type.label("group_provider_type"),
  153. ModelUsage.provider_id.label("group_provider_id"),
  154. ], [
  155. ModelUsage.cluster_name,
  156. ModelUsage.model_name,
  157. ModelUsage.model_id,
  158. ModelUsage.provider_name,
  159. ModelUsage.provider_type,
  160. ModelUsage.provider_id,
  161. ]
  162. if group_by == USAGE_GROUP_BY_USER:
  163. return [
  164. ModelUsage.user_name.label("group_user_name"),
  165. ModelUsage.user_id.label("group_user_id"),
  166. ], [ModelUsage.user_name, ModelUsage.user_id]
  167. return [
  168. ModelUsage.user_name.label("group_user_name"),
  169. ModelUsage.api_key_name.label("group_api_key_name"),
  170. ModelUsage.access_key.label("group_access_key"),
  171. ModelUsage.api_key_is_custom.label("group_api_key_is_custom"),
  172. ModelUsage.user_id.label("group_user_id"),
  173. ModelUsage.api_key_id.label("group_api_key_id"),
  174. ], [
  175. ModelUsage.user_name,
  176. ModelUsage.api_key_name,
  177. ModelUsage.access_key,
  178. ModelUsage.api_key_is_custom,
  179. ModelUsage.user_id,
  180. ModelUsage.api_key_id,
  181. ]
  182. def _combined_group_columns(group_bys: List[str], *, date_bucket_expr=None):
  183. select_columns = []
  184. select_column_keys = set()
  185. group_columns = []
  186. group_column_keys = set()
  187. for group_by in group_bys:
  188. current_select_columns, current_group_columns = _group_columns(
  189. group_by, date_bucket_expr=date_bucket_expr
  190. )
  191. for column in current_select_columns:
  192. key = getattr(column, "key", None) or str(column)
  193. if key in select_column_keys:
  194. continue
  195. select_column_keys.add(key)
  196. select_columns.append(column)
  197. for column in current_group_columns:
  198. key = str(column)
  199. if key in group_column_keys:
  200. continue
  201. group_column_keys.add(key)
  202. group_columns.append(column)
  203. return select_columns, group_columns
  204. def _row_identity(group_by: str, row: Any) -> UsageIdentity:
  205. if group_by == USAGE_GROUP_BY_MODEL:
  206. model_id = getattr(row, "group_model_id", None)
  207. provider_id = getattr(row, "group_provider_id", None)
  208. current = None
  209. if model_id is not None or provider_id is not None:
  210. current = UsageIdentityCurrent(
  211. model_id=model_id,
  212. provider_id=provider_id,
  213. )
  214. return UsageIdentity(
  215. value=UsageIdentityValue(
  216. cluster_name=getattr(row, "group_cluster_name", None),
  217. model_name=getattr(row, "group_model_name", None),
  218. provider_name=getattr(row, "group_provider_name", None),
  219. provider_type=getattr(row, "group_provider_type", None),
  220. ),
  221. current=current,
  222. )
  223. if group_by == USAGE_GROUP_BY_USER:
  224. user_id = getattr(row, "group_user_id", None)
  225. return UsageIdentity(
  226. value=UsageIdentityValue(user_name=getattr(row, "group_user_name", None)),
  227. current=None if user_id is None else UsageIdentityCurrent(user_id=user_id),
  228. )
  229. api_key_id = getattr(row, "group_api_key_id", None)
  230. current = None
  231. if api_key_id is not None:
  232. current = UsageIdentityCurrent(
  233. user_id=getattr(row, "group_user_id", None),
  234. api_key_id=api_key_id,
  235. )
  236. return UsageIdentity(
  237. value=UsageIdentityValue(
  238. user_name=getattr(row, "group_user_name", None),
  239. api_key_name=getattr(row, "group_api_key_name", None),
  240. access_key=getattr(row, "group_access_key", None),
  241. api_key_is_custom=getattr(row, "group_api_key_is_custom", None),
  242. ),
  243. current=current,
  244. )
  245. def _row_date_dimension(row: Any, granularity: str) -> UsageBreakdownDateDimension:
  246. value = _coerce_date(row.group_date)
  247. return UsageBreakdownDateDimension(
  248. value=value, label=format_usage_date_label(value, granularity)
  249. )
  250. def _coerce_date(value: Any) -> date:
  251. if isinstance(value, datetime):
  252. return value.date()
  253. if isinstance(value, date):
  254. return value
  255. if isinstance(value, str):
  256. return date.fromisoformat(value[:10])
  257. return value
  258. def _row_dimension(group_by: str, row: Any) -> UsageBreakdownDimension:
  259. if group_by == USAGE_GROUP_BY_API_KEY and not getattr(
  260. row, "group_api_key_name", None
  261. ):
  262. return UsageBreakdownDimension(
  263. identity=None,
  264. label="-",
  265. deleted=False,
  266. )
  267. identity = _row_identity(group_by, row)
  268. return UsageBreakdownDimension(
  269. identity=identity,
  270. label=_identity_label(group_by, identity),
  271. deleted=_identity_deleted(identity),
  272. )
  273. def _identity_deleted(identity: UsageIdentity) -> bool:
  274. return identity.current is None
  275. def _identity_label(group_by: str, identity: UsageIdentity) -> str:
  276. value = identity.value
  277. if group_by == USAGE_GROUP_BY_MODEL:
  278. label = format_usage_model_label(
  279. model_name=value.model_name,
  280. cluster_name=value.cluster_name,
  281. provider_name=value.provider_name,
  282. )
  283. elif group_by == USAGE_GROUP_BY_USER:
  284. label = format_usage_user_label(value.user_name)
  285. else:
  286. label = format_usage_api_key_label(
  287. user_name=value.user_name,
  288. api_key_name=value.api_key_name,
  289. )
  290. if _identity_deleted(identity):
  291. label = f"{label} (Deleted)"
  292. return label
  293. def _filter_condition(group_by: str, item: UsageFilterItem):
  294. value = item.identity.value
  295. current = item.identity.current
  296. conditions = []
  297. if group_by == USAGE_GROUP_BY_MODEL:
  298. conditions.extend(
  299. [
  300. _null_safe_column_filter(ModelUsage.cluster_name, value.cluster_name),
  301. _null_safe_column_filter(ModelUsage.model_name, value.model_name),
  302. _null_safe_column_filter(ModelUsage.provider_name, value.provider_name),
  303. _null_safe_column_filter(ModelUsage.provider_type, value.provider_type),
  304. ]
  305. )
  306. if current is None or current.model_id is None:
  307. conditions.append(ModelUsage.model_id.is_(None))
  308. else:
  309. conditions.append(ModelUsage.model_id == current.model_id)
  310. if current is None or current.provider_id is None:
  311. conditions.append(ModelUsage.provider_id.is_(None))
  312. else:
  313. conditions.append(ModelUsage.provider_id == current.provider_id)
  314. elif group_by == USAGE_GROUP_BY_USER:
  315. conditions.append(
  316. _null_safe_column_filter(ModelUsage.user_name, value.user_name)
  317. )
  318. if current is None or current.user_id is None:
  319. conditions.append(ModelUsage.user_id.is_(None))
  320. else:
  321. conditions.append(ModelUsage.user_id == current.user_id)
  322. else:
  323. conditions.extend(
  324. [
  325. _null_safe_column_filter(ModelUsage.user_name, value.user_name),
  326. _null_safe_column_filter(ModelUsage.api_key_name, value.api_key_name),
  327. _null_safe_column_filter(ModelUsage.access_key, value.access_key),
  328. ]
  329. )
  330. if value.api_key_is_custom is not None:
  331. conditions.append(
  332. _null_safe_column_filter(
  333. ModelUsage.api_key_is_custom, value.api_key_is_custom
  334. )
  335. )
  336. if current is None or current.api_key_id is None:
  337. conditions.append(ModelUsage.api_key_id.is_(None))
  338. else:
  339. conditions.append(ModelUsage.api_key_id == current.api_key_id)
  340. conditions.append(
  341. _null_safe_column_filter(ModelUsage.user_id, current.user_id)
  342. )
  343. return and_(*conditions)
  344. def _can_use_all_scope(user: User, ctx) -> bool:
  345. """The cross-user (``all``) view is meaningful for platform admin
  346. and real Org admin only. Others fall back to ``self``.
  347. Personal Org admin doesn't qualify even though their ``org_role``
  348. is ADMIN: a Personal Org has exactly one member, so ``all`` would
  349. just be ``self`` — and worse, the org_id filter applied for ``all``
  350. would hide the user's own usage from Platform-shared models, since
  351. those rows carry the model owner's owner_principal_id, not the
  352. Personal Org's.
  353. """
  354. if user.is_admin:
  355. return True
  356. if ctx is None:
  357. return False
  358. if ctx.org_role != OrgRole.ADMIN:
  359. return False
  360. return not ctx.current_is_personal_scope
  361. def _resolve_effective_scope(user: User, ctx, requested_scope: str) -> str:
  362. """Map requested scope onto what the caller actually gets.
  363. - ``self`` always allowed.
  364. - ``all`` allowed for managers / admin; non-managers asking for
  365. ``all`` are silently downgraded to ``self`` (the request default
  366. is ``all``, so a regular user with no explicit scope shouldn't
  367. hit a 403). Privacy-sensitive group_bys (e.g. ``user``) are
  368. rejected later in ``_check_permission`` once the effective scope
  369. is locked.
  370. """
  371. if requested_scope == USAGE_SCOPE_SELF:
  372. return USAGE_SCOPE_SELF
  373. if not _can_use_all_scope(user, ctx):
  374. return USAGE_SCOPE_SELF
  375. return USAGE_SCOPE_ALL
  376. def _apply_usage_scope_and_filters(
  377. statement: Select,
  378. *,
  379. user: User,
  380. filters,
  381. scope: str,
  382. org_id: Optional[int] = None,
  383. ) -> Select:
  384. # ``self`` view always restricts to the caller's own rows. ``all``
  385. # view restricts by owner_principal_id when one is in context (platform
  386. # admin in cross-org "All" mode has org_id=None and sees everything).
  387. if scope == USAGE_SCOPE_SELF:
  388. statement = statement.where(ModelUsage.user_id == user.id)
  389. elif org_id is not None:
  390. statement = statement.where(ModelUsage.owner_principal_id == org_id)
  391. for group_by, items in [
  392. (USAGE_GROUP_BY_MODEL, filters.models),
  393. (USAGE_GROUP_BY_USER, filters.users),
  394. (USAGE_GROUP_BY_API_KEY, filters.api_keys),
  395. ]:
  396. if not items:
  397. continue
  398. if group_by == USAGE_GROUP_BY_USER and scope == USAGE_SCOPE_SELF:
  399. raise ForbiddenException(message="No permission to filter by user")
  400. statement = statement.where(
  401. or_(*[_filter_condition(group_by, item) for item in items])
  402. )
  403. return statement
  404. def _base_statement() -> Select:
  405. return select().select_from(ModelUsage)
  406. def _date_scoped_statement(
  407. statement: Select, start_date: date, end_date: date
  408. ) -> Select:
  409. return statement.where(ModelUsage.date >= start_date).where(
  410. ModelUsage.date <= end_date
  411. )
  412. def _exclude_incomplete_api_key_identity(statement: Select) -> Select:
  413. return (
  414. statement.where(ModelUsage.api_key_name.is_not(None))
  415. .where(ModelUsage.api_key_name != "")
  416. .where(ModelUsage.access_key.is_not(None))
  417. .where(ModelUsage.access_key != "")
  418. )
  419. async def _get_rows(session, statement: Select):
  420. return (await session.exec(statement)).all()
  421. async def _get_first_row(session, statement: Select):
  422. rows = await _get_rows(session, statement)
  423. return rows[0] if rows else None
  424. def _summary_from_row(row: Any) -> UsageSummary:
  425. if row is None:
  426. return UsageSummary()
  427. return UsageSummary(
  428. input_tokens=int(getattr(row, USAGE_METRIC_INPUT_TOKENS, 0) or 0),
  429. output_tokens=int(getattr(row, USAGE_METRIC_OUTPUT_TOKENS, 0) or 0),
  430. input_cached_tokens=int(getattr(row, USAGE_METRIC_INPUT_CACHED_TOKENS, 0) or 0),
  431. total_tokens=int(getattr(row, USAGE_METRIC_TOTAL_TOKENS, 0) or 0),
  432. api_requests=int(getattr(row, USAGE_METRIC_API_REQUESTS, 0) or 0),
  433. models_called=int(getattr(row, USAGE_METRIC_MODELS_CALLED, 0) or 0),
  434. )
  435. def _option_from_identity(group_by: str, identity: UsageIdentity) -> UsageFilterOption:
  436. return UsageFilterOption(
  437. identity=identity,
  438. label=_identity_label(group_by, identity),
  439. deleted=_identity_deleted(identity),
  440. )
  441. async def _get_filter_options(
  442. session,
  443. *,
  444. base_statement: Select,
  445. group_by: str,
  446. ) -> List[UsageFilterOption]:
  447. select_columns, group_columns = _group_columns(group_by)
  448. if group_by == USAGE_GROUP_BY_API_KEY:
  449. base_statement = _exclude_incomplete_api_key_identity(base_statement)
  450. rows = await _get_rows(
  451. session,
  452. base_statement.with_only_columns(*select_columns)
  453. .distinct()
  454. .order_by(*group_columns),
  455. )
  456. return [
  457. _option_from_identity(group_by, _row_identity(group_by, row)) for row in rows
  458. ]
  459. @router.get("/meta", response_model=UsageMetaResponse, response_model_exclude_none=True)
  460. async def get_usage_meta(
  461. session: SessionDep,
  462. user: CurrentUserDep,
  463. ctx: TenantContextDep,
  464. scope: str = USAGE_SCOPE_ALL,
  465. ):
  466. # Default scope is "all"; downgrades to "self" for non-managers so
  467. # the page works without an explicit scope param.
  468. if scope == USAGE_SCOPE_ALL and not _can_use_all_scope(user, ctx):
  469. scope = USAGE_SCOPE_SELF
  470. elif scope not in (USAGE_SCOPE_SELF, USAGE_SCOPE_ALL):
  471. raise InvalidException(message=f"Unsupported scope: {scope}")
  472. base_statement = _base_statement()
  473. if scope == USAGE_SCOPE_SELF:
  474. base_statement = base_statement.where(ModelUsage.user_id == user.id)
  475. elif ctx.current_principal_id is not None:
  476. base_statement = base_statement.where(
  477. ModelUsage.owner_principal_id == ctx.current_principal_id
  478. )
  479. model_options = await _get_filter_options(
  480. session, base_statement=base_statement, group_by=USAGE_GROUP_BY_MODEL
  481. )
  482. user_options: List[UsageFilterOption] = []
  483. if scope == USAGE_SCOPE_ALL:
  484. user_options = await _get_filter_options(
  485. session, base_statement=base_statement, group_by=USAGE_GROUP_BY_USER
  486. )
  487. api_key_options = await _get_filter_options(
  488. session, base_statement=base_statement, group_by=USAGE_GROUP_BY_API_KEY
  489. )
  490. return UsageMetaResponse(
  491. metrics=METRIC_OPTIONS,
  492. granularities=GRANULARITY_OPTIONS,
  493. group_bys=(
  494. ADMIN_GROUP_BY_OPTIONS
  495. if scope == USAGE_SCOPE_ALL
  496. else SELF_GROUP_BY_OPTIONS
  497. ),
  498. filters=UsageFilters(
  499. models=model_options,
  500. users=user_options,
  501. api_keys=api_key_options,
  502. ),
  503. )
  504. def _check_permission(user: User, ctx, request, effective_scope: str) -> None:
  505. """``mine`` view forbids the user-grouping / user-filter dimensions
  506. (privacy: a user can only see their own rows). ``org`` view allows
  507. them since the caller is admin / owner / manager."""
  508. group_by = request.group_by
  509. group_bys = group_by if isinstance(group_by, list) else [group_by]
  510. if effective_scope == USAGE_SCOPE_SELF:
  511. if USAGE_GROUP_BY_USER in group_bys:
  512. raise ForbiddenException(message="No permission to group by user")
  513. if request.filters.users:
  514. raise ForbiddenException(message="No permission to filter by user")
  515. def _sort_expression(sort_by: str, metric_columns: Dict[str, Any], date_sort_expr=None):
  516. if sort_by == USAGE_METRIC_DATE:
  517. return date_sort_expr if date_sort_expr is not None else ModelUsage.date
  518. if sort_by == USAGE_METRIC_AVG_TOKENS_PER_REQUEST:
  519. return metric_columns[USAGE_METRIC_TOTAL_TOKENS] / func.nullif(
  520. metric_columns[USAGE_METRIC_API_REQUESTS], 0
  521. )
  522. if sort_by == USAGE_METRIC_API_KEYS_USED:
  523. return func.count(func.distinct(ModelUsage.access_key))
  524. if sort_by == USAGE_METRIC_LAST_ACTIVE:
  525. return func.max(ModelUsage.date)
  526. if sort_by in metric_columns:
  527. return metric_columns[sort_by]
  528. return metric_columns[USAGE_METRIC_TOTAL_TOKENS]
  529. def _order_expression(
  530. order_by: List[tuple[str, str]], metric_columns: Dict[str, Any], date_sort_expr=None
  531. ):
  532. if not order_by:
  533. order_by = [(USAGE_METRIC_TOTAL_TOKENS, USAGE_SORT_DESC)]
  534. sort_exprs = []
  535. for sort_by, direction in order_by:
  536. sort_expr = _sort_expression(sort_by, metric_columns, date_sort_expr)
  537. sort_exprs.append(
  538. desc(sort_expr) if direction == USAGE_SORT_DESC else asc(sort_expr)
  539. )
  540. return sort_exprs
  541. def _row_count_value(row: Any) -> int:
  542. if row is None:
  543. return 0
  544. if isinstance(row, tuple):
  545. return int(row[0] or 0)
  546. return int(row or 0)
  547. def _single_group_by(group_bys: List[str], group_by: str) -> bool:
  548. return len(group_bys) == 1 and group_bys[0] == group_by
  549. def _breakdown_bucket_granularity(request: UsageBreakdownRequest) -> str:
  550. if USAGE_GROUP_BY_DATE not in request.group_by:
  551. return USAGE_GRANULARITY_DAY
  552. return request.granularity or USAGE_GRANULARITY_DAY
  553. def _build_breakdown_item(
  554. group_bys: List[str], row: Any, granularity: str
  555. ) -> UsageBreakdownItem:
  556. api_requests = int(getattr(row, USAGE_METRIC_API_REQUESTS, 0) or 0)
  557. total_tokens = int(getattr(row, USAGE_METRIC_TOTAL_TOKENS, 0) or 0)
  558. breakdown_item = UsageBreakdownItem(
  559. input_tokens=int(getattr(row, USAGE_METRIC_INPUT_TOKENS, 0) or 0),
  560. output_tokens=int(getattr(row, USAGE_METRIC_OUTPUT_TOKENS, 0) or 0),
  561. input_cached_tokens=int(getattr(row, USAGE_METRIC_INPUT_CACHED_TOKENS, 0) or 0),
  562. total_tokens=total_tokens,
  563. api_requests=api_requests,
  564. avg_tokens_per_request=total_tokens / api_requests if api_requests else 0,
  565. last_active=getattr(row, USAGE_METRIC_LAST_ACTIVE, None),
  566. )
  567. if USAGE_GROUP_BY_DATE in group_bys:
  568. breakdown_item.date = _row_date_dimension(row, granularity)
  569. if USAGE_GROUP_BY_MODEL in group_bys:
  570. breakdown_item.model = _row_dimension(USAGE_GROUP_BY_MODEL, row)
  571. if USAGE_GROUP_BY_USER in group_bys:
  572. breakdown_item.user = _row_dimension(USAGE_GROUP_BY_USER, row)
  573. if USAGE_GROUP_BY_API_KEY in group_bys:
  574. breakdown_item.api_key = _row_dimension(USAGE_GROUP_BY_API_KEY, row)
  575. if _single_group_by(group_bys, USAGE_GROUP_BY_USER):
  576. breakdown_item.models_called = int(
  577. getattr(row, USAGE_METRIC_MODELS_CALLED, 0) or 0
  578. )
  579. breakdown_item.api_keys_used = int(
  580. getattr(row, USAGE_METRIC_API_KEYS_USED, 0) or 0
  581. )
  582. elif _single_group_by(group_bys, USAGE_GROUP_BY_API_KEY):
  583. breakdown_item.models_called = int(
  584. getattr(row, USAGE_METRIC_MODELS_CALLED, 0) or 0
  585. )
  586. return breakdown_item
  587. @router.post(
  588. "/breakdown",
  589. response_model=UsageBreakdownResponse,
  590. response_model_exclude_none=True,
  591. )
  592. async def get_usage_breakdown(
  593. session: SessionDep,
  594. user: CurrentUserDep,
  595. ctx: TenantContextDep,
  596. request: UsageBreakdownRequest,
  597. ):
  598. effective_scope = _resolve_effective_scope(user, ctx, request.scope)
  599. _check_permission(user, ctx, request, effective_scope)
  600. metric_columns = _metric_columns()
  601. base_statement = _apply_usage_scope_and_filters(
  602. _base_statement(),
  603. user=user,
  604. filters=request.filters,
  605. scope=effective_scope,
  606. org_id=ctx.current_principal_id,
  607. )
  608. if _single_group_by(request.group_by, USAGE_GROUP_BY_API_KEY):
  609. base_statement = _exclude_incomplete_api_key_identity(base_statement)
  610. scoped_statement = _date_scoped_statement(
  611. base_statement, request.start_date, request.end_date
  612. )
  613. summary_columns = [metric_columns[item].label(item) for item in metric_columns]
  614. summary_row = await _get_first_row(
  615. session, scoped_statement.with_only_columns(*summary_columns)
  616. )
  617. granularity = _breakdown_bucket_granularity(request)
  618. date_bucket_expr = _date_bucket_expression(session, granularity)
  619. select_columns, group_columns = _combined_group_columns(
  620. request.group_by, date_bucket_expr=date_bucket_expr
  621. )
  622. aggregate_columns = [
  623. metric_columns[USAGE_METRIC_INPUT_TOKENS].label(USAGE_METRIC_INPUT_TOKENS),
  624. metric_columns[USAGE_METRIC_OUTPUT_TOKENS].label(USAGE_METRIC_OUTPUT_TOKENS),
  625. metric_columns[USAGE_METRIC_INPUT_CACHED_TOKENS].label(
  626. USAGE_METRIC_INPUT_CACHED_TOKENS
  627. ),
  628. metric_columns[USAGE_METRIC_TOTAL_TOKENS].label(USAGE_METRIC_TOTAL_TOKENS),
  629. metric_columns[USAGE_METRIC_API_REQUESTS].label(USAGE_METRIC_API_REQUESTS),
  630. metric_columns[USAGE_METRIC_MODELS_CALLED].label(USAGE_METRIC_MODELS_CALLED),
  631. func.count(func.distinct(ModelUsage.access_key)).label(
  632. USAGE_METRIC_API_KEYS_USED
  633. ),
  634. func.max(ModelUsage.date).label(USAGE_METRIC_LAST_ACTIVE),
  635. ]
  636. grouped_statement = scoped_statement.with_only_columns(
  637. *select_columns, *aggregate_columns
  638. ).group_by(*group_columns)
  639. count_statement = select(func.count()).select_from(grouped_statement.subquery())
  640. date_sort_expr = (
  641. date_bucket_expr
  642. if USAGE_GROUP_BY_DATE in request.group_by
  643. else func.max(ModelUsage.date)
  644. )
  645. sort_exprs = _order_expression(request.order_by, metric_columns, date_sort_expr)
  646. items_statement = (
  647. grouped_statement.order_by(*sort_exprs)
  648. .offset((request.page - 1) * request.perPage)
  649. .limit(request.perPage)
  650. )
  651. total = _row_count_value(await _get_first_row(session, count_statement))
  652. item_rows = await _get_rows(session, items_statement)
  653. return UsageBreakdownResponse(
  654. summary=_summary_from_row(summary_row),
  655. group_by=request.group_by,
  656. granularity=granularity if USAGE_GROUP_BY_DATE in request.group_by else None,
  657. pagination=Pagination(
  658. page=request.page,
  659. perPage=request.perPage,
  660. total=total,
  661. totalPage=ceil(total / request.perPage) if total else 0,
  662. ),
  663. items=[
  664. _build_breakdown_item(request.group_by, row, granularity)
  665. for row in item_rows
  666. ],
  667. )