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.
- app/__init__.py +5 -0
- app/cli.py +24 -0
- app/core/__init__.py +0 -0
- app/core/auth/__init__.py +96 -0
- app/core/auth/models.py +49 -0
- app/core/auth/refresh.py +144 -0
- app/core/balancer/__init__.py +19 -0
- app/core/balancer/logic.py +140 -0
- app/core/balancer/types.py +9 -0
- app/core/clients/__init__.py +0 -0
- app/core/clients/http.py +39 -0
- app/core/clients/oauth.py +340 -0
- app/core/clients/proxy.py +265 -0
- app/core/clients/usage.py +143 -0
- app/core/config/__init__.py +0 -0
- app/core/config/settings.py +69 -0
- app/core/crypto.py +37 -0
- app/core/errors.py +73 -0
- app/core/openai/__init__.py +0 -0
- app/core/openai/models.py +122 -0
- app/core/openai/parsing.py +55 -0
- app/core/openai/requests.py +59 -0
- app/core/types.py +4 -0
- app/core/usage/__init__.py +185 -0
- app/core/usage/logs.py +57 -0
- app/core/usage/models.py +35 -0
- app/core/usage/pricing.py +172 -0
- app/core/usage/types.py +95 -0
- app/core/utils/__init__.py +0 -0
- app/core/utils/request_id.py +30 -0
- app/core/utils/retry.py +16 -0
- app/core/utils/sse.py +13 -0
- app/core/utils/time.py +19 -0
- app/db/__init__.py +0 -0
- app/db/models.py +82 -0
- app/db/session.py +44 -0
- app/dependencies.py +123 -0
- app/main.py +124 -0
- app/modules/__init__.py +0 -0
- app/modules/accounts/__init__.py +0 -0
- app/modules/accounts/api.py +81 -0
- app/modules/accounts/repository.py +80 -0
- app/modules/accounts/schemas.py +66 -0
- app/modules/accounts/service.py +211 -0
- app/modules/health/__init__.py +0 -0
- app/modules/health/api.py +10 -0
- app/modules/oauth/__init__.py +0 -0
- app/modules/oauth/api.py +57 -0
- app/modules/oauth/schemas.py +32 -0
- app/modules/oauth/service.py +356 -0
- app/modules/oauth/templates/oauth_success.html +122 -0
- app/modules/proxy/__init__.py +0 -0
- app/modules/proxy/api.py +76 -0
- app/modules/proxy/auth_manager.py +51 -0
- app/modules/proxy/load_balancer.py +208 -0
- app/modules/proxy/schemas.py +85 -0
- app/modules/proxy/service.py +707 -0
- app/modules/proxy/types.py +37 -0
- app/modules/proxy/usage_updater.py +147 -0
- app/modules/request_logs/__init__.py +0 -0
- app/modules/request_logs/api.py +31 -0
- app/modules/request_logs/repository.py +86 -0
- app/modules/request_logs/schemas.py +25 -0
- app/modules/request_logs/service.py +77 -0
- app/modules/shared/__init__.py +0 -0
- app/modules/shared/schemas.py +8 -0
- app/modules/usage/__init__.py +0 -0
- app/modules/usage/api.py +31 -0
- app/modules/usage/repository.py +113 -0
- app/modules/usage/schemas.py +62 -0
- app/modules/usage/service.py +246 -0
- app/static/7.css +1336 -0
- app/static/index.css +543 -0
- app/static/index.html +457 -0
- app/static/index.js +1898 -0
- codex_lb-0.1.2.dist-info/METADATA +108 -0
- codex_lb-0.1.2.dist-info/RECORD +80 -0
- codex_lb-0.1.2.dist-info/WHEEL +4 -0
- codex_lb-0.1.2.dist-info/entry_points.txt +2 -0
- 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
|
|
File without changes
|
app/modules/usage/api.py
ADDED
|
@@ -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)
|