codex-lb 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. app/__init__.py +5 -0
  2. app/cli.py +24 -0
  3. app/core/__init__.py +0 -0
  4. app/core/auth/__init__.py +96 -0
  5. app/core/auth/models.py +49 -0
  6. app/core/auth/refresh.py +144 -0
  7. app/core/balancer/__init__.py +19 -0
  8. app/core/balancer/logic.py +140 -0
  9. app/core/balancer/types.py +9 -0
  10. app/core/clients/__init__.py +0 -0
  11. app/core/clients/http.py +39 -0
  12. app/core/clients/oauth.py +340 -0
  13. app/core/clients/proxy.py +265 -0
  14. app/core/clients/usage.py +143 -0
  15. app/core/config/__init__.py +0 -0
  16. app/core/config/settings.py +69 -0
  17. app/core/crypto.py +37 -0
  18. app/core/errors.py +73 -0
  19. app/core/openai/__init__.py +0 -0
  20. app/core/openai/models.py +122 -0
  21. app/core/openai/parsing.py +55 -0
  22. app/core/openai/requests.py +59 -0
  23. app/core/types.py +4 -0
  24. app/core/usage/__init__.py +185 -0
  25. app/core/usage/logs.py +57 -0
  26. app/core/usage/models.py +35 -0
  27. app/core/usage/pricing.py +172 -0
  28. app/core/usage/types.py +95 -0
  29. app/core/utils/__init__.py +0 -0
  30. app/core/utils/request_id.py +30 -0
  31. app/core/utils/retry.py +16 -0
  32. app/core/utils/sse.py +13 -0
  33. app/core/utils/time.py +19 -0
  34. app/db/__init__.py +0 -0
  35. app/db/models.py +82 -0
  36. app/db/session.py +44 -0
  37. app/dependencies.py +123 -0
  38. app/main.py +124 -0
  39. app/modules/__init__.py +0 -0
  40. app/modules/accounts/__init__.py +0 -0
  41. app/modules/accounts/api.py +81 -0
  42. app/modules/accounts/repository.py +80 -0
  43. app/modules/accounts/schemas.py +66 -0
  44. app/modules/accounts/service.py +211 -0
  45. app/modules/health/__init__.py +0 -0
  46. app/modules/health/api.py +10 -0
  47. app/modules/oauth/__init__.py +0 -0
  48. app/modules/oauth/api.py +57 -0
  49. app/modules/oauth/schemas.py +32 -0
  50. app/modules/oauth/service.py +356 -0
  51. app/modules/oauth/templates/oauth_success.html +122 -0
  52. app/modules/proxy/__init__.py +0 -0
  53. app/modules/proxy/api.py +76 -0
  54. app/modules/proxy/auth_manager.py +51 -0
  55. app/modules/proxy/load_balancer.py +208 -0
  56. app/modules/proxy/schemas.py +85 -0
  57. app/modules/proxy/service.py +707 -0
  58. app/modules/proxy/types.py +37 -0
  59. app/modules/proxy/usage_updater.py +147 -0
  60. app/modules/request_logs/__init__.py +0 -0
  61. app/modules/request_logs/api.py +31 -0
  62. app/modules/request_logs/repository.py +86 -0
  63. app/modules/request_logs/schemas.py +25 -0
  64. app/modules/request_logs/service.py +77 -0
  65. app/modules/shared/__init__.py +0 -0
  66. app/modules/shared/schemas.py +8 -0
  67. app/modules/usage/__init__.py +0 -0
  68. app/modules/usage/api.py +31 -0
  69. app/modules/usage/repository.py +113 -0
  70. app/modules/usage/schemas.py +62 -0
  71. app/modules/usage/service.py +246 -0
  72. app/static/7.css +1336 -0
  73. app/static/index.css +543 -0
  74. app/static/index.html +457 -0
  75. app/static/index.js +1898 -0
  76. codex_lb-0.1.2.dist-info/METADATA +108 -0
  77. codex_lb-0.1.2.dist-info/RECORD +80 -0
  78. codex_lb-0.1.2.dist-info/WHEEL +4 -0
  79. codex_lb-0.1.2.dist-info/entry_points.txt +2 -0
  80. codex_lb-0.1.2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from app.core.types import JsonValue
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class RateLimitWindowSnapshotData:
10
+ used_percent: int
11
+ limit_window_seconds: int
12
+ reset_after_seconds: int
13
+ reset_at: int
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class RateLimitStatusDetailsData:
18
+ allowed: bool
19
+ limit_reached: bool
20
+ primary_window: RateLimitWindowSnapshotData | None = None
21
+ secondary_window: RateLimitWindowSnapshotData | None = None
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class CreditStatusDetailsData:
26
+ has_credits: bool
27
+ unlimited: bool
28
+ balance: str | None = None
29
+ approx_local_messages: list[JsonValue] | None = None
30
+ approx_cloud_messages: list[JsonValue] | None = None
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class RateLimitStatusPayloadData:
35
+ plan_type: str
36
+ rate_limit: RateLimitStatusDetailsData | None = None
37
+ credits: CreditStatusDetailsData | None = None
@@ -0,0 +1,147 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import math
5
+ from typing import Mapping
6
+
7
+ from app.core.auth.refresh import RefreshError
8
+ from app.core.clients.usage import UsageFetchError, fetch_usage
9
+ from app.core.config.settings import get_settings
10
+ from app.core.crypto import TokenEncryptor
11
+ from app.core.usage.models import UsagePayload
12
+ from app.core.utils.request_id import get_request_id
13
+ from app.core.utils.time import utcnow
14
+ from app.db.models import Account, AccountStatus, UsageHistory
15
+ from app.modules.accounts.repository import AccountsRepository
16
+ from app.modules.proxy.auth_manager import AuthManager
17
+ from app.modules.usage.repository import UsageRepository
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class UsageUpdater:
23
+ def __init__(
24
+ self,
25
+ usage_repo: UsageRepository,
26
+ accounts_repo: AccountsRepository | None = None,
27
+ ) -> None:
28
+ self._usage_repo = usage_repo
29
+ self._encryptor = TokenEncryptor()
30
+ self._auth_manager = AuthManager(accounts_repo) if accounts_repo else None
31
+
32
+ async def refresh_accounts(
33
+ self,
34
+ accounts: list[Account],
35
+ latest_usage: Mapping[str, UsageHistory],
36
+ ) -> None:
37
+ settings = get_settings()
38
+ if not settings.usage_refresh_enabled:
39
+ return
40
+
41
+ now = utcnow()
42
+ interval = settings.usage_refresh_interval_seconds
43
+ for account in accounts:
44
+ if account.status == AccountStatus.DEACTIVATED:
45
+ continue
46
+ latest = latest_usage.get(account.id)
47
+ if latest and (now - latest.recorded_at).total_seconds() < interval:
48
+ continue
49
+ # NOTE: AsyncSession is not safe for concurrent use. Run sequentially
50
+ # within the request-scoped session to avoid PK collisions and
51
+ # flush-time warnings (SAWarning: Session.add during flush).
52
+ try:
53
+ await self._refresh_account(account)
54
+ except Exception as exc:
55
+ logger.warning(
56
+ "Usage refresh failed account_id=%s request_id=%s error=%s",
57
+ account.id,
58
+ get_request_id(),
59
+ exc,
60
+ exc_info=True,
61
+ )
62
+ # swallow per-account failures so the whole refresh loop keeps going
63
+ continue
64
+
65
+ async def _refresh_account(self, account: Account) -> None:
66
+ access_token = self._encryptor.decrypt(account.access_token_encrypted)
67
+ try:
68
+ payload = await fetch_usage(
69
+ access_token=access_token,
70
+ account_id=account.id,
71
+ )
72
+ except UsageFetchError as exc:
73
+ if exc.status_code != 401 or not self._auth_manager:
74
+ return
75
+ try:
76
+ account = await self._auth_manager.ensure_fresh(account, force=True)
77
+ except RefreshError:
78
+ return
79
+ access_token = self._encryptor.decrypt(account.access_token_encrypted)
80
+ try:
81
+ payload = await fetch_usage(
82
+ access_token=access_token,
83
+ account_id=account.id,
84
+ )
85
+ except UsageFetchError:
86
+ return
87
+
88
+ rate_limit = payload.rate_limit
89
+ primary = rate_limit.primary_window if rate_limit else None
90
+ credits_has, credits_unlimited, credits_balance = _credits_snapshot(payload)
91
+ primary_window_minutes = _window_minutes(primary.limit_window_seconds) if primary else None
92
+ secondary = rate_limit.secondary_window if rate_limit else None
93
+ secondary_window_minutes = _window_minutes(secondary.limit_window_seconds) if secondary else None
94
+
95
+ if primary and primary.used_percent is not None:
96
+ await self._usage_repo.add_entry(
97
+ account_id=account.id,
98
+ used_percent=primary.used_percent,
99
+ input_tokens=None,
100
+ output_tokens=None,
101
+ window="primary",
102
+ reset_at=primary.reset_at,
103
+ window_minutes=primary_window_minutes,
104
+ credits_has=credits_has,
105
+ credits_unlimited=credits_unlimited,
106
+ credits_balance=credits_balance,
107
+ )
108
+
109
+ if secondary and secondary.used_percent is not None:
110
+ await self._usage_repo.add_entry(
111
+ account_id=account.id,
112
+ used_percent=secondary.used_percent,
113
+ input_tokens=None,
114
+ output_tokens=None,
115
+ window="secondary",
116
+ reset_at=secondary.reset_at,
117
+ window_minutes=secondary_window_minutes,
118
+ )
119
+
120
+
121
+ def _credits_snapshot(payload: UsagePayload) -> tuple[bool | None, bool | None, float | None]:
122
+ credits = payload.credits
123
+ if credits is None:
124
+ return None, None, None
125
+ credits_has = credits.has_credits
126
+ credits_unlimited = credits.unlimited
127
+ balance_value = credits.balance
128
+ return credits_has, credits_unlimited, _parse_credits_balance(balance_value)
129
+
130
+
131
+ def _parse_credits_balance(value: str | int | float | None) -> float | None:
132
+ if value is None:
133
+ return None
134
+ if isinstance(value, (int, float)):
135
+ return float(value)
136
+ if isinstance(value, str):
137
+ try:
138
+ return float(value.strip())
139
+ except ValueError:
140
+ return None
141
+ return None
142
+
143
+
144
+ def _window_minutes(limit_seconds: int | None) -> int | None:
145
+ if not limit_seconds or limit_seconds <= 0:
146
+ return None
147
+ return max(1, math.ceil(limit_seconds / 60))
File without changes
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+
5
+ from fastapi import APIRouter, Depends, Query
6
+
7
+ from app.dependencies import RequestLogsContext, get_request_logs_context
8
+ from app.modules.request_logs.schemas import RequestLogsResponse
9
+
10
+ router = APIRouter(prefix="/api/request-logs", tags=["dashboard"])
11
+
12
+
13
+ @router.get("", response_model=RequestLogsResponse)
14
+ async def list_request_logs(
15
+ limit: int = Query(50, ge=1, le=200),
16
+ account_id: str | None = Query(default=None, alias="accountId"),
17
+ status: str | None = Query(default=None),
18
+ model: str | None = Query(default=None),
19
+ since: datetime | None = Query(default=None),
20
+ until: datetime | None = Query(default=None),
21
+ context: RequestLogsContext = Depends(get_request_logs_context),
22
+ ) -> RequestLogsResponse:
23
+ logs = await context.service.list_recent(
24
+ limit=limit,
25
+ since=since,
26
+ until=until,
27
+ account_id=account_id,
28
+ model=model,
29
+ status=status,
30
+ )
31
+ return RequestLogsResponse(requests=logs)
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+
5
+ from sqlalchemy import and_, select
6
+ from sqlalchemy.ext.asyncio import AsyncSession
7
+
8
+ from app.core.utils.request_id import ensure_request_id
9
+ from app.core.utils.time import utcnow
10
+ from app.db.models import RequestLog
11
+
12
+
13
+ class RequestLogsRepository:
14
+ def __init__(self, session: AsyncSession) -> None:
15
+ self._session = session
16
+
17
+ async def list_since(self, since: datetime) -> list[RequestLog]:
18
+ result = await self._session.execute(select(RequestLog).where(RequestLog.requested_at >= since))
19
+ return list(result.scalars().all())
20
+
21
+ async def add_log(
22
+ self,
23
+ account_id: str,
24
+ request_id: str,
25
+ model: str,
26
+ input_tokens: int | None,
27
+ output_tokens: int | None,
28
+ latency_ms: int | None,
29
+ status: str,
30
+ error_code: str | None,
31
+ error_message: str | None = None,
32
+ requested_at: datetime | None = None,
33
+ cached_input_tokens: int | None = None,
34
+ reasoning_tokens: int | None = None,
35
+ ) -> RequestLog:
36
+ resolved_request_id = ensure_request_id(request_id)
37
+ log = RequestLog(
38
+ account_id=account_id,
39
+ request_id=resolved_request_id,
40
+ model=model,
41
+ input_tokens=input_tokens,
42
+ output_tokens=output_tokens,
43
+ cached_input_tokens=cached_input_tokens,
44
+ reasoning_tokens=reasoning_tokens,
45
+ latency_ms=latency_ms,
46
+ status=status,
47
+ error_code=error_code,
48
+ error_message=error_message,
49
+ requested_at=requested_at or utcnow(),
50
+ )
51
+ self._session.add(log)
52
+ await self._session.commit()
53
+ await self._session.refresh(log)
54
+ return log
55
+
56
+ async def list_recent(
57
+ self,
58
+ limit: int = 50,
59
+ since: datetime | None = None,
60
+ until: datetime | None = None,
61
+ account_id: str | None = None,
62
+ model: str | None = None,
63
+ status: str | None = None,
64
+ error_codes: list[str] | None = None,
65
+ ) -> list[RequestLog]:
66
+ conditions = []
67
+ if since is not None:
68
+ conditions.append(RequestLog.requested_at >= since)
69
+ if until is not None:
70
+ conditions.append(RequestLog.requested_at <= until)
71
+ if account_id is not None:
72
+ conditions.append(RequestLog.account_id == account_id)
73
+ if model is not None:
74
+ conditions.append(RequestLog.model == model)
75
+ if status is not None:
76
+ conditions.append(RequestLog.status == status)
77
+ if error_codes:
78
+ conditions.append(RequestLog.error_code.in_(error_codes))
79
+
80
+ stmt = select(RequestLog).order_by(RequestLog.requested_at.desc())
81
+ if conditions:
82
+ stmt = stmt.where(and_(*conditions))
83
+ if limit:
84
+ stmt = stmt.limit(limit)
85
+ result = await self._session.execute(stmt)
86
+ return list(result.scalars().all())
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from typing import List
5
+
6
+ from pydantic import Field
7
+
8
+ from app.modules.shared.schemas import DashboardModel
9
+
10
+
11
+ class RequestLogEntry(DashboardModel):
12
+ requested_at: datetime
13
+ account_id: str
14
+ request_id: str
15
+ model: str
16
+ status: str
17
+ error_code: str | None = None
18
+ error_message: str | None = None
19
+ tokens: int | None = None
20
+ cost_usd: float | None = None
21
+ latency_ms: int | None = None
22
+
23
+
24
+ class RequestLogsResponse(DashboardModel):
25
+ requests: List[RequestLogEntry] = Field(default_factory=list)
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+
5
+ from app.core.usage.logs import cost_from_log, total_tokens_from_log
6
+ from app.db.models import RequestLog
7
+ from app.modules.request_logs.repository import RequestLogsRepository
8
+ from app.modules.request_logs.schemas import RequestLogEntry
9
+
10
+ RATE_LIMIT_CODES = {"rate_limit_exceeded", "usage_limit_reached"}
11
+ QUOTA_CODES = {"insufficient_quota", "usage_not_included", "quota_exceeded"}
12
+
13
+
14
+ class RequestLogsService:
15
+ def __init__(self, repo: RequestLogsRepository) -> None:
16
+ self._repo = repo
17
+
18
+ async def list_recent(
19
+ self,
20
+ limit: int = 50,
21
+ since: datetime | None = None,
22
+ until: datetime | None = None,
23
+ account_id: str | None = None,
24
+ model: str | None = None,
25
+ status: str | None = None,
26
+ ) -> list[RequestLogEntry]:
27
+ status_filter, error_codes = _map_status_filter(status)
28
+ logs = await self._repo.list_recent(
29
+ limit=limit,
30
+ since=since,
31
+ until=until,
32
+ account_id=account_id,
33
+ model=model,
34
+ status=status_filter,
35
+ error_codes=error_codes,
36
+ )
37
+ return [_to_entry(log) for log in logs]
38
+
39
+
40
+ def _map_status_filter(status: str | None) -> tuple[str | None, list[str] | None]:
41
+ if not status:
42
+ return None, None
43
+ normalized = status.lower()
44
+ if normalized == "ok":
45
+ return "success", None
46
+ if normalized == "rate_limit":
47
+ return "error", sorted(RATE_LIMIT_CODES)
48
+ if normalized == "quota":
49
+ return "error", sorted(QUOTA_CODES)
50
+ if normalized == "error":
51
+ return "error", None
52
+ return status, None
53
+
54
+
55
+ def _log_status(log: RequestLog) -> str:
56
+ if log.status == "success":
57
+ return "ok"
58
+ if log.error_code in RATE_LIMIT_CODES:
59
+ return "rate_limit"
60
+ if log.error_code in QUOTA_CODES:
61
+ return "quota"
62
+ return "error"
63
+
64
+
65
+ def _to_entry(log: RequestLog) -> RequestLogEntry:
66
+ return RequestLogEntry(
67
+ requested_at=log.requested_at,
68
+ account_id=log.account_id,
69
+ request_id=log.request_id,
70
+ model=log.model,
71
+ status=_log_status(log),
72
+ error_code=log.error_code,
73
+ error_message=log.error_message,
74
+ tokens=total_tokens_from_log(log),
75
+ cost_usd=cost_from_log(log, precision=6),
76
+ latency_ms=log.latency_ms,
77
+ )
File without changes
@@ -0,0 +1,8 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel, ConfigDict
4
+ from pydantic.alias_generators import to_camel
5
+
6
+
7
+ class DashboardModel(BaseModel):
8
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
File without changes
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import APIRouter, Depends, Query
4
+
5
+ from app.dependencies import UsageContext, get_usage_context
6
+ from app.modules.usage.schemas import UsageHistoryResponse, UsageSummaryResponse, UsageWindowResponse
7
+
8
+ router = APIRouter(prefix="/api/usage", tags=["dashboard"])
9
+
10
+
11
+ @router.get("/summary", response_model=UsageSummaryResponse)
12
+ async def get_usage_summary(
13
+ context: UsageContext = Depends(get_usage_context),
14
+ ) -> UsageSummaryResponse:
15
+ return await context.service.get_usage_summary()
16
+
17
+
18
+ @router.get("/history", response_model=UsageHistoryResponse)
19
+ async def get_usage_history(
20
+ hours: int = Query(24, ge=1, le=168),
21
+ context: UsageContext = Depends(get_usage_context),
22
+ ) -> UsageHistoryResponse:
23
+ return await context.service.get_usage_history(hours)
24
+
25
+
26
+ @router.get("/window", response_model=UsageWindowResponse)
27
+ async def get_usage_window(
28
+ window: str = Query("primary", pattern="^(primary|secondary)$"),
29
+ context: UsageContext = Depends(get_usage_context),
30
+ ) -> UsageWindowResponse:
31
+ return await context.service.get_usage_window(window)
@@ -0,0 +1,113 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+
5
+ from sqlalchemy import func, or_, select
6
+ from sqlalchemy.ext.asyncio import AsyncSession
7
+
8
+ from app.core.usage.types import UsageAggregateRow
9
+ from app.core.utils.time import utcnow
10
+ from app.db.models import UsageHistory
11
+
12
+
13
+ class UsageRepository:
14
+ def __init__(self, session: AsyncSession) -> None:
15
+ self._session = session
16
+
17
+ async def add_entry(
18
+ self,
19
+ account_id: str,
20
+ used_percent: float,
21
+ input_tokens: int | None = None,
22
+ output_tokens: int | None = None,
23
+ recorded_at: datetime | None = None,
24
+ window: str | None = None,
25
+ reset_at: int | None = None,
26
+ window_minutes: int | None = None,
27
+ credits_has: bool | None = None,
28
+ credits_unlimited: bool | None = None,
29
+ credits_balance: float | None = None,
30
+ ) -> UsageHistory:
31
+ entry = UsageHistory(
32
+ account_id=account_id,
33
+ used_percent=used_percent,
34
+ input_tokens=input_tokens,
35
+ output_tokens=output_tokens,
36
+ window=window,
37
+ reset_at=reset_at,
38
+ window_minutes=window_minutes,
39
+ credits_has=credits_has,
40
+ credits_unlimited=credits_unlimited,
41
+ credits_balance=credits_balance,
42
+ recorded_at=recorded_at or utcnow(),
43
+ )
44
+ self._session.add(entry)
45
+ await self._session.commit()
46
+ await self._session.refresh(entry)
47
+ return entry
48
+
49
+ async def aggregate_since(
50
+ self,
51
+ since: datetime,
52
+ window: str | None = None,
53
+ ) -> list[UsageAggregateRow]:
54
+ conditions = [UsageHistory.recorded_at >= since]
55
+ if window:
56
+ if window == "primary":
57
+ conditions.append(or_(UsageHistory.window == "primary", UsageHistory.window.is_(None)))
58
+ else:
59
+ conditions.append(UsageHistory.window == window)
60
+ stmt = (
61
+ select(
62
+ UsageHistory.account_id,
63
+ func.avg(UsageHistory.used_percent).label("used_percent_avg"),
64
+ func.sum(UsageHistory.input_tokens).label("input_tokens_sum"),
65
+ func.sum(UsageHistory.output_tokens).label("output_tokens_sum"),
66
+ func.count(UsageHistory.id).label("samples"),
67
+ func.max(UsageHistory.recorded_at).label("last_recorded_at"),
68
+ func.max(UsageHistory.reset_at).label("reset_at_max"),
69
+ func.max(UsageHistory.window_minutes).label("window_minutes_max"),
70
+ )
71
+ .where(*conditions)
72
+ .group_by(UsageHistory.account_id)
73
+ )
74
+ result = await self._session.execute(stmt)
75
+ rows = result.all()
76
+ return [
77
+ UsageAggregateRow(
78
+ account_id=row.account_id,
79
+ used_percent_avg=float(row.used_percent_avg) if row.used_percent_avg is not None else None,
80
+ input_tokens_sum=int(row.input_tokens_sum) if row.input_tokens_sum is not None else None,
81
+ output_tokens_sum=int(row.output_tokens_sum) if row.output_tokens_sum is not None else None,
82
+ samples=int(row.samples),
83
+ last_recorded_at=row.last_recorded_at,
84
+ reset_at_max=int(row.reset_at_max) if row.reset_at_max is not None else None,
85
+ window_minutes_max=int(row.window_minutes_max) if row.window_minutes_max is not None else None,
86
+ )
87
+ for row in rows
88
+ ]
89
+
90
+ async def latest_by_account(self, window: str | None = None) -> dict[str, UsageHistory]:
91
+ if window:
92
+ if window == "primary":
93
+ conditions = or_(UsageHistory.window == "primary", UsageHistory.window.is_(None))
94
+ else:
95
+ conditions = UsageHistory.window == window
96
+ else:
97
+ conditions = or_(UsageHistory.window == "primary", UsageHistory.window.is_(None))
98
+ stmt = select(UsageHistory).where(conditions).order_by(UsageHistory.account_id, UsageHistory.recorded_at.desc())
99
+ result = await self._session.execute(stmt)
100
+ latest: dict[str, UsageHistory] = {}
101
+ for entry in result.scalars().all():
102
+ if entry.account_id not in latest:
103
+ latest[entry.account_id] = entry
104
+ return latest
105
+
106
+ async def latest_window_minutes(self, window: str) -> int | None:
107
+ if window == "primary":
108
+ conditions = or_(UsageHistory.window == "primary", UsageHistory.window.is_(None))
109
+ else:
110
+ conditions = UsageHistory.window == window
111
+ result = await self._session.execute(select(func.max(UsageHistory.window_minutes)).where(conditions))
112
+ value = result.scalar_one_or_none()
113
+ return int(value) if value is not None else None
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from typing import List
5
+
6
+ from pydantic import Field
7
+
8
+ from app.modules.shared.schemas import DashboardModel
9
+
10
+
11
+ class UsageWindow(DashboardModel):
12
+ remaining_percent: float
13
+ capacity_credits: float
14
+ remaining_credits: float
15
+ reset_at: datetime | None = None
16
+ window_minutes: int | None = None
17
+
18
+
19
+ class UsageCostByModel(DashboardModel):
20
+ model: str
21
+ usd: float
22
+
23
+
24
+ class UsageCost(DashboardModel):
25
+ currency: str
26
+ total_usd_7d: float = Field(alias="totalUsd7d")
27
+ by_model: List[UsageCostByModel] = Field(default_factory=list)
28
+
29
+
30
+ class UsageMetrics(DashboardModel):
31
+ requests_7d: int | None = Field(default=None, alias="requests7d")
32
+ tokens_secondary_window: int | None = None
33
+ error_rate_7d: float | None = Field(default=None, alias="errorRate7d")
34
+ top_error: str | None = None
35
+
36
+
37
+ class UsageSummaryResponse(DashboardModel):
38
+ primary_window: UsageWindow
39
+ secondary_window: UsageWindow | None = None
40
+ cost: UsageCost
41
+ metrics: UsageMetrics | None = None
42
+
43
+
44
+ class UsageHistoryItem(DashboardModel):
45
+ account_id: str
46
+ email: str
47
+ remaining_percent_avg: float
48
+ capacity_credits: float
49
+ remaining_credits: float
50
+ request_count: int
51
+ cost_usd: float
52
+
53
+
54
+ class UsageHistoryResponse(DashboardModel):
55
+ window_hours: int
56
+ accounts: List[UsageHistoryItem] = Field(default_factory=list)
57
+
58
+
59
+ class UsageWindowResponse(DashboardModel):
60
+ window_key: str
61
+ window_minutes: int | None = None
62
+ accounts: List[UsageHistoryItem] = Field(default_factory=list)