codex-lb 0.3.1__py3-none-any.whl → 0.4.0__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/core/clients/proxy.py +33 -3
- app/core/config/settings.py +1 -0
- app/core/openai/requests.py +21 -3
- app/core/openai/v1_requests.py +148 -0
- app/db/models.py +3 -3
- app/main.py +1 -0
- app/modules/accounts/repository.py +4 -1
- app/modules/proxy/api.py +36 -0
- app/modules/proxy/service.py +29 -0
- app/modules/request_logs/api.py +61 -7
- app/modules/request_logs/repository.py +128 -16
- app/modules/request_logs/schemas.py +11 -2
- app/modules/request_logs/service.py +97 -20
- app/modules/usage/updater.py +58 -26
- app/static/index.css +378 -1
- app/static/index.html +183 -8
- app/static/index.js +308 -13
- {codex_lb-0.3.1.dist-info → codex_lb-0.4.0.dist-info}/METADATA +41 -3
- {codex_lb-0.3.1.dist-info → codex_lb-0.4.0.dist-info}/RECORD +22 -21
- {codex_lb-0.3.1.dist-info → codex_lb-0.4.0.dist-info}/WHEEL +0 -0
- {codex_lb-0.3.1.dist-info → codex_lb-0.4.0.dist-info}/entry_points.txt +0 -0
- {codex_lb-0.3.1.dist-info → codex_lb-0.4.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -3,12 +3,12 @@ from __future__ import annotations
|
|
|
3
3
|
from datetime import datetime
|
|
4
4
|
|
|
5
5
|
import anyio
|
|
6
|
-
from sqlalchemy import and_, select
|
|
6
|
+
from sqlalchemy import String, and_, cast, or_, select
|
|
7
7
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
8
8
|
|
|
9
9
|
from app.core.utils.request_id import ensure_request_id
|
|
10
10
|
from app.core.utils.time import utcnow
|
|
11
|
-
from app.db.models import RequestLog
|
|
11
|
+
from app.db.models import Account, RequestLog
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
class RequestLogsRepository:
|
|
@@ -63,35 +63,147 @@ class RequestLogsRepository:
|
|
|
63
63
|
async def list_recent(
|
|
64
64
|
self,
|
|
65
65
|
limit: int = 50,
|
|
66
|
+
offset: int = 0,
|
|
67
|
+
search: str | None = None,
|
|
66
68
|
since: datetime | None = None,
|
|
67
69
|
until: datetime | None = None,
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
account_ids: list[str] | None = None,
|
|
71
|
+
model_options: list[tuple[str, str | None]] | None = None,
|
|
72
|
+
models: list[str] | None = None,
|
|
73
|
+
reasoning_efforts: list[str] | None = None,
|
|
74
|
+
include_success: bool = True,
|
|
75
|
+
include_error_other: bool = True,
|
|
76
|
+
error_codes_in: list[str] | None = None,
|
|
77
|
+
error_codes_excluding: list[str] | None = None,
|
|
72
78
|
) -> list[RequestLog]:
|
|
73
79
|
conditions = []
|
|
74
80
|
if since is not None:
|
|
75
81
|
conditions.append(RequestLog.requested_at >= since)
|
|
76
82
|
if until is not None:
|
|
77
83
|
conditions.append(RequestLog.requested_at <= until)
|
|
78
|
-
if
|
|
79
|
-
conditions.append(RequestLog.account_id
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
84
|
+
if account_ids:
|
|
85
|
+
conditions.append(RequestLog.account_id.in_(account_ids))
|
|
86
|
+
|
|
87
|
+
if model_options:
|
|
88
|
+
pair_conditions = []
|
|
89
|
+
for model, effort in model_options:
|
|
90
|
+
base = (model or "").strip()
|
|
91
|
+
if not base:
|
|
92
|
+
continue
|
|
93
|
+
if effort is None:
|
|
94
|
+
pair_conditions.append(and_(RequestLog.model == base, RequestLog.reasoning_effort.is_(None)))
|
|
95
|
+
else:
|
|
96
|
+
pair_conditions.append(and_(RequestLog.model == base, RequestLog.reasoning_effort == effort))
|
|
97
|
+
if pair_conditions:
|
|
98
|
+
conditions.append(or_(*pair_conditions))
|
|
99
|
+
else:
|
|
100
|
+
if models:
|
|
101
|
+
conditions.append(RequestLog.model.in_(models))
|
|
102
|
+
if reasoning_efforts:
|
|
103
|
+
conditions.append(RequestLog.reasoning_effort.in_(reasoning_efforts))
|
|
104
|
+
|
|
105
|
+
status_conditions = []
|
|
106
|
+
if include_success:
|
|
107
|
+
status_conditions.append(RequestLog.status == "success")
|
|
108
|
+
if error_codes_in:
|
|
109
|
+
status_conditions.append(and_(RequestLog.status == "error", RequestLog.error_code.in_(error_codes_in)))
|
|
110
|
+
if include_error_other:
|
|
111
|
+
error_clause = [RequestLog.status == "error"]
|
|
112
|
+
if error_codes_excluding:
|
|
113
|
+
error_clause.append(
|
|
114
|
+
or_(
|
|
115
|
+
RequestLog.error_code.is_(None),
|
|
116
|
+
~RequestLog.error_code.in_(error_codes_excluding),
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
status_conditions.append(and_(*error_clause))
|
|
120
|
+
if status_conditions:
|
|
121
|
+
conditions.append(or_(*status_conditions))
|
|
122
|
+
if search:
|
|
123
|
+
search_pattern = f"%{search}%"
|
|
124
|
+
conditions.append(
|
|
125
|
+
or_(
|
|
126
|
+
RequestLog.account_id.ilike(search_pattern),
|
|
127
|
+
Account.email.ilike(search_pattern),
|
|
128
|
+
RequestLog.request_id.ilike(search_pattern),
|
|
129
|
+
RequestLog.model.ilike(search_pattern),
|
|
130
|
+
RequestLog.reasoning_effort.ilike(search_pattern),
|
|
131
|
+
RequestLog.status.ilike(search_pattern),
|
|
132
|
+
RequestLog.error_code.ilike(search_pattern),
|
|
133
|
+
RequestLog.error_message.ilike(search_pattern),
|
|
134
|
+
cast(RequestLog.requested_at, String).ilike(search_pattern),
|
|
135
|
+
cast(RequestLog.input_tokens, String).ilike(search_pattern),
|
|
136
|
+
cast(RequestLog.output_tokens, String).ilike(search_pattern),
|
|
137
|
+
cast(RequestLog.cached_input_tokens, String).ilike(search_pattern),
|
|
138
|
+
cast(RequestLog.reasoning_tokens, String).ilike(search_pattern),
|
|
139
|
+
cast(RequestLog.latency_ms, String).ilike(search_pattern),
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
stmt = (
|
|
144
|
+
select(RequestLog)
|
|
145
|
+
.outerjoin(Account, Account.id == RequestLog.account_id)
|
|
146
|
+
.order_by(RequestLog.requested_at.desc())
|
|
147
|
+
)
|
|
88
148
|
if conditions:
|
|
89
149
|
stmt = stmt.where(and_(*conditions))
|
|
150
|
+
if offset:
|
|
151
|
+
stmt = stmt.offset(offset)
|
|
90
152
|
if limit:
|
|
91
153
|
stmt = stmt.limit(limit)
|
|
92
154
|
result = await self._session.execute(stmt)
|
|
93
155
|
return list(result.scalars().all())
|
|
94
156
|
|
|
157
|
+
async def list_filter_options(
|
|
158
|
+
self,
|
|
159
|
+
since: datetime | None = None,
|
|
160
|
+
until: datetime | None = None,
|
|
161
|
+
include_success: bool = True,
|
|
162
|
+
include_error_other: bool = True,
|
|
163
|
+
error_codes_in: list[str] | None = None,
|
|
164
|
+
error_codes_excluding: list[str] | None = None,
|
|
165
|
+
) -> tuple[list[str], list[tuple[str, str | None]]]:
|
|
166
|
+
conditions = []
|
|
167
|
+
if since is not None:
|
|
168
|
+
conditions.append(RequestLog.requested_at >= since)
|
|
169
|
+
if until is not None:
|
|
170
|
+
conditions.append(RequestLog.requested_at <= until)
|
|
171
|
+
status_conditions = []
|
|
172
|
+
if include_success:
|
|
173
|
+
status_conditions.append(RequestLog.status == "success")
|
|
174
|
+
if error_codes_in:
|
|
175
|
+
status_conditions.append(and_(RequestLog.status == "error", RequestLog.error_code.in_(error_codes_in)))
|
|
176
|
+
if include_error_other:
|
|
177
|
+
error_clause = [RequestLog.status == "error"]
|
|
178
|
+
if error_codes_excluding:
|
|
179
|
+
error_clause.append(
|
|
180
|
+
or_(
|
|
181
|
+
RequestLog.error_code.is_(None),
|
|
182
|
+
~RequestLog.error_code.in_(error_codes_excluding),
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
status_conditions.append(and_(*error_clause))
|
|
186
|
+
if status_conditions:
|
|
187
|
+
conditions.append(or_(*status_conditions))
|
|
188
|
+
|
|
189
|
+
account_stmt = select(RequestLog.account_id).distinct().order_by(RequestLog.account_id.asc())
|
|
190
|
+
model_stmt = (
|
|
191
|
+
select(RequestLog.model, RequestLog.reasoning_effort)
|
|
192
|
+
.distinct()
|
|
193
|
+
.order_by(RequestLog.model.asc(), RequestLog.reasoning_effort.asc())
|
|
194
|
+
)
|
|
195
|
+
if conditions:
|
|
196
|
+
clause = and_(*conditions)
|
|
197
|
+
account_stmt = account_stmt.where(clause)
|
|
198
|
+
model_stmt = model_stmt.where(clause)
|
|
199
|
+
|
|
200
|
+
account_rows = await self._session.execute(account_stmt)
|
|
201
|
+
model_rows = await self._session.execute(model_stmt)
|
|
202
|
+
|
|
203
|
+
account_ids = [row[0] for row in account_rows.all() if row[0]]
|
|
204
|
+
model_options = [(row[0], row[1]) for row in model_rows.all() if row[0]]
|
|
205
|
+
return account_ids, model_options
|
|
206
|
+
|
|
95
207
|
|
|
96
208
|
async def _safe_rollback(session: AsyncSession) -> None:
|
|
97
209
|
if not session.in_transaction():
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from datetime import datetime
|
|
4
|
-
from typing import List
|
|
5
4
|
|
|
6
5
|
from pydantic import Field
|
|
7
6
|
|
|
@@ -24,4 +23,14 @@ class RequestLogEntry(DashboardModel):
|
|
|
24
23
|
|
|
25
24
|
|
|
26
25
|
class RequestLogsResponse(DashboardModel):
|
|
27
|
-
requests:
|
|
26
|
+
requests: list[RequestLogEntry] = Field(default_factory=list)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class RequestLogModelOption(DashboardModel):
|
|
30
|
+
model: str
|
|
31
|
+
reasoning_effort: str | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class RequestLogFilterOptionsResponse(DashboardModel):
|
|
35
|
+
account_ids: list[str] = Field(default_factory=list)
|
|
36
|
+
model_options: list[RequestLogModelOption] = Field(default_factory=list)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
from dataclasses import dataclass
|
|
3
4
|
from datetime import datetime
|
|
4
5
|
from typing import cast
|
|
5
6
|
|
|
@@ -17,6 +18,26 @@ RATE_LIMIT_CODES = {"rate_limit_exceeded", "usage_limit_reached"}
|
|
|
17
18
|
QUOTA_CODES = {"insufficient_quota", "usage_not_included", "quota_exceeded"}
|
|
18
19
|
|
|
19
20
|
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class RequestLogModelOption:
|
|
23
|
+
model: str
|
|
24
|
+
reasoning_effort: str | None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class RequestLogStatusFilter:
|
|
29
|
+
include_success: bool
|
|
30
|
+
include_error_other: bool
|
|
31
|
+
error_codes_in: list[str] | None
|
|
32
|
+
error_codes_excluding: list[str] | None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class RequestLogFilterOptions:
|
|
37
|
+
account_ids: list[str]
|
|
38
|
+
model_options: list[RequestLogModelOption]
|
|
39
|
+
|
|
40
|
+
|
|
20
41
|
class RequestLogsService:
|
|
21
42
|
def __init__(self, repo: RequestLogsRepository) -> None:
|
|
22
43
|
self._repo = repo
|
|
@@ -24,38 +45,94 @@ class RequestLogsService:
|
|
|
24
45
|
async def list_recent(
|
|
25
46
|
self,
|
|
26
47
|
limit: int = 50,
|
|
48
|
+
offset: int = 0,
|
|
49
|
+
search: str | None = None,
|
|
27
50
|
since: datetime | None = None,
|
|
28
51
|
until: datetime | None = None,
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
52
|
+
account_ids: list[str] | None = None,
|
|
53
|
+
model_options: list[RequestLogModelOption] | None = None,
|
|
54
|
+
models: list[str] | None = None,
|
|
55
|
+
reasoning_efforts: list[str] | None = None,
|
|
56
|
+
status: list[str] | None = None,
|
|
32
57
|
) -> list[RequestLogEntry]:
|
|
33
|
-
status_filter
|
|
58
|
+
status_filter = _map_status_filter(status)
|
|
34
59
|
logs = await self._repo.list_recent(
|
|
35
60
|
limit=limit,
|
|
61
|
+
offset=offset,
|
|
62
|
+
search=search,
|
|
36
63
|
since=since,
|
|
37
64
|
until=until,
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
65
|
+
account_ids=account_ids,
|
|
66
|
+
model_options=(
|
|
67
|
+
[(option.model, option.reasoning_effort) for option in model_options] if model_options else None
|
|
68
|
+
),
|
|
69
|
+
models=models,
|
|
70
|
+
reasoning_efforts=reasoning_efforts,
|
|
71
|
+
include_success=status_filter.include_success,
|
|
72
|
+
include_error_other=status_filter.include_error_other,
|
|
73
|
+
error_codes_in=status_filter.error_codes_in,
|
|
74
|
+
error_codes_excluding=status_filter.error_codes_excluding,
|
|
42
75
|
)
|
|
43
76
|
return [_to_entry(log) for log in logs]
|
|
44
77
|
|
|
78
|
+
async def list_filter_options(
|
|
79
|
+
self,
|
|
80
|
+
since: datetime | None = None,
|
|
81
|
+
until: datetime | None = None,
|
|
82
|
+
status: list[str] | None = None,
|
|
83
|
+
) -> RequestLogFilterOptions:
|
|
84
|
+
status_filter = _map_status_filter(status)
|
|
85
|
+
account_ids, model_options = await self._repo.list_filter_options(
|
|
86
|
+
since=since,
|
|
87
|
+
until=until,
|
|
88
|
+
include_success=status_filter.include_success,
|
|
89
|
+
include_error_other=status_filter.include_error_other,
|
|
90
|
+
error_codes_in=status_filter.error_codes_in,
|
|
91
|
+
error_codes_excluding=status_filter.error_codes_excluding,
|
|
92
|
+
)
|
|
93
|
+
return RequestLogFilterOptions(
|
|
94
|
+
account_ids=account_ids,
|
|
95
|
+
model_options=[
|
|
96
|
+
RequestLogModelOption(model=model, reasoning_effort=reasoning_effort)
|
|
97
|
+
for model, reasoning_effort in model_options
|
|
98
|
+
],
|
|
99
|
+
)
|
|
100
|
+
|
|
45
101
|
|
|
46
|
-
def _map_status_filter(status: str | None) ->
|
|
102
|
+
def _map_status_filter(status: list[str] | None) -> RequestLogStatusFilter:
|
|
47
103
|
if not status:
|
|
48
|
-
return
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
104
|
+
return RequestLogStatusFilter(
|
|
105
|
+
include_success=True,
|
|
106
|
+
include_error_other=True,
|
|
107
|
+
error_codes_in=None,
|
|
108
|
+
error_codes_excluding=None,
|
|
109
|
+
)
|
|
110
|
+
normalized = {value.lower() for value in status if value}
|
|
111
|
+
if not normalized or "all" in normalized:
|
|
112
|
+
return RequestLogStatusFilter(
|
|
113
|
+
include_success=True,
|
|
114
|
+
include_error_other=True,
|
|
115
|
+
error_codes_in=None,
|
|
116
|
+
error_codes_excluding=None,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
include_success = "ok" in normalized
|
|
120
|
+
include_rate_limit = "rate_limit" in normalized
|
|
121
|
+
include_quota = "quota" in normalized
|
|
122
|
+
include_error_other = "error" in normalized
|
|
123
|
+
|
|
124
|
+
error_codes_in: set[str] = set()
|
|
125
|
+
if include_rate_limit:
|
|
126
|
+
error_codes_in |= RATE_LIMIT_CODES
|
|
127
|
+
if include_quota:
|
|
128
|
+
error_codes_in |= QUOTA_CODES
|
|
129
|
+
|
|
130
|
+
return RequestLogStatusFilter(
|
|
131
|
+
include_success=include_success,
|
|
132
|
+
include_error_other=include_error_other,
|
|
133
|
+
error_codes_in=sorted(error_codes_in) if error_codes_in else None,
|
|
134
|
+
error_codes_excluding=sorted(RATE_LIMIT_CODES | QUOTA_CODES) if include_error_other else None,
|
|
135
|
+
)
|
|
59
136
|
|
|
60
137
|
|
|
61
138
|
def _log_status(log: RequestLog) -> str:
|
app/modules/usage/updater.py
CHANGED
|
@@ -2,8 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
4
|
import math
|
|
5
|
-
from
|
|
6
|
-
from datetime import datetime
|
|
5
|
+
from datetime import datetime, timezone
|
|
7
6
|
from typing import Mapping, Protocol
|
|
8
7
|
|
|
9
8
|
from app.core.auth.refresh import RefreshError
|
|
@@ -14,8 +13,7 @@ from app.core.usage.models import UsagePayload
|
|
|
14
13
|
from app.core.utils.request_id import get_request_id
|
|
15
14
|
from app.core.utils.time import utcnow
|
|
16
15
|
from app.db.models import Account, AccountStatus, UsageHistory
|
|
17
|
-
from app.modules.accounts.auth_manager import AuthManager
|
|
18
|
-
from app.modules.accounts.repository import AccountsRepository
|
|
16
|
+
from app.modules.accounts.auth_manager import AccountsRepositoryPort, AuthManager
|
|
19
17
|
|
|
20
18
|
logger = logging.getLogger(__name__)
|
|
21
19
|
|
|
@@ -41,7 +39,7 @@ class UsageUpdater:
|
|
|
41
39
|
def __init__(
|
|
42
40
|
self,
|
|
43
41
|
usage_repo: UsageRepositoryPort,
|
|
44
|
-
accounts_repo:
|
|
42
|
+
accounts_repo: AccountsRepositoryPort | None = None,
|
|
45
43
|
) -> None:
|
|
46
44
|
self._usage_repo = usage_repo
|
|
47
45
|
self._encryptor = TokenEncryptor()
|
|
@@ -56,7 +54,6 @@ class UsageUpdater:
|
|
|
56
54
|
if not settings.usage_refresh_enabled:
|
|
57
55
|
return
|
|
58
56
|
|
|
59
|
-
shared_chatgpt_account_ids = _shared_chatgpt_account_ids(accounts)
|
|
60
57
|
now = utcnow()
|
|
61
58
|
interval = settings.usage_refresh_interval_seconds
|
|
62
59
|
for account in accounts:
|
|
@@ -65,16 +62,11 @@ class UsageUpdater:
|
|
|
65
62
|
latest = latest_usage.get(account.id)
|
|
66
63
|
if latest and (now - latest.recorded_at).total_seconds() < interval:
|
|
67
64
|
continue
|
|
68
|
-
usage_account_id = (
|
|
69
|
-
None
|
|
70
|
-
if account.chatgpt_account_id and account.chatgpt_account_id in shared_chatgpt_account_ids
|
|
71
|
-
else account.chatgpt_account_id
|
|
72
|
-
)
|
|
73
65
|
# NOTE: AsyncSession is not safe for concurrent use. Run sequentially
|
|
74
66
|
# within the request-scoped session to avoid PK collisions and
|
|
75
67
|
# flush-time warnings (SAWarning: Session.add during flush).
|
|
76
68
|
try:
|
|
77
|
-
await self._refresh_account(account, usage_account_id=
|
|
69
|
+
await self._refresh_account(account, usage_account_id=account.chatgpt_account_id)
|
|
78
70
|
except Exception as exc:
|
|
79
71
|
logger.warning(
|
|
80
72
|
"Usage refresh failed account_id=%s request_id=%s error=%s",
|
|
@@ -88,12 +80,16 @@ class UsageUpdater:
|
|
|
88
80
|
|
|
89
81
|
async def _refresh_account(self, account: Account, *, usage_account_id: str | None) -> None:
|
|
90
82
|
access_token = self._encryptor.decrypt(account.access_token_encrypted)
|
|
83
|
+
payload: UsagePayload | None = None
|
|
91
84
|
try:
|
|
92
85
|
payload = await fetch_usage(
|
|
93
86
|
access_token=access_token,
|
|
94
87
|
account_id=usage_account_id,
|
|
95
88
|
)
|
|
96
89
|
except UsageFetchError as exc:
|
|
90
|
+
if _should_deactivate_for_usage_error(exc.status_code):
|
|
91
|
+
await self._deactivate_for_client_error(account, exc)
|
|
92
|
+
return
|
|
97
93
|
if exc.status_code != 401 or not self._auth_manager:
|
|
98
94
|
return
|
|
99
95
|
try:
|
|
@@ -106,25 +102,32 @@ class UsageUpdater:
|
|
|
106
102
|
access_token=access_token,
|
|
107
103
|
account_id=usage_account_id,
|
|
108
104
|
)
|
|
109
|
-
except UsageFetchError:
|
|
105
|
+
except UsageFetchError as retry_exc:
|
|
106
|
+
if _should_deactivate_for_usage_error(retry_exc.status_code):
|
|
107
|
+
await self._deactivate_for_client_error(account, retry_exc)
|
|
110
108
|
return
|
|
111
109
|
|
|
110
|
+
if payload is None:
|
|
111
|
+
return
|
|
112
|
+
|
|
112
113
|
rate_limit = payload.rate_limit
|
|
113
|
-
|
|
114
|
+
if rate_limit is None:
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
primary = rate_limit.primary_window
|
|
118
|
+
secondary = rate_limit.secondary_window
|
|
114
119
|
credits_has, credits_unlimited, credits_balance = _credits_snapshot(payload)
|
|
115
|
-
|
|
116
|
-
secondary = rate_limit.secondary_window if rate_limit else None
|
|
117
|
-
secondary_window_minutes = _window_minutes(secondary.limit_window_seconds) if secondary else None
|
|
120
|
+
now_epoch = _now_epoch()
|
|
118
121
|
|
|
119
122
|
if primary and primary.used_percent is not None:
|
|
120
123
|
await self._usage_repo.add_entry(
|
|
121
124
|
account_id=account.id,
|
|
122
|
-
used_percent=primary.used_percent,
|
|
125
|
+
used_percent=float(primary.used_percent),
|
|
123
126
|
input_tokens=None,
|
|
124
127
|
output_tokens=None,
|
|
125
128
|
window="primary",
|
|
126
|
-
reset_at=primary.reset_at,
|
|
127
|
-
window_minutes=
|
|
129
|
+
reset_at=_reset_at(primary.reset_at, primary.reset_after_seconds, now_epoch),
|
|
130
|
+
window_minutes=_window_minutes(primary.limit_window_seconds),
|
|
128
131
|
credits_has=credits_has,
|
|
129
132
|
credits_unlimited=credits_unlimited,
|
|
130
133
|
credits_balance=credits_balance,
|
|
@@ -133,14 +136,29 @@ class UsageUpdater:
|
|
|
133
136
|
if secondary and secondary.used_percent is not None:
|
|
134
137
|
await self._usage_repo.add_entry(
|
|
135
138
|
account_id=account.id,
|
|
136
|
-
used_percent=secondary.used_percent,
|
|
139
|
+
used_percent=float(secondary.used_percent),
|
|
137
140
|
input_tokens=None,
|
|
138
141
|
output_tokens=None,
|
|
139
142
|
window="secondary",
|
|
140
|
-
reset_at=secondary.reset_at,
|
|
141
|
-
window_minutes=
|
|
143
|
+
reset_at=_reset_at(secondary.reset_at, secondary.reset_after_seconds, now_epoch),
|
|
144
|
+
window_minutes=_window_minutes(secondary.limit_window_seconds),
|
|
142
145
|
)
|
|
143
146
|
|
|
147
|
+
async def _deactivate_for_client_error(self, account: Account, exc: UsageFetchError) -> None:
|
|
148
|
+
if not self._auth_manager:
|
|
149
|
+
return
|
|
150
|
+
reason = f"Usage API error: HTTP {exc.status_code} - {exc.message}"
|
|
151
|
+
logger.warning(
|
|
152
|
+
"Deactivating account due to client error account_id=%s status=%s message=%s request_id=%s",
|
|
153
|
+
account.id,
|
|
154
|
+
exc.status_code,
|
|
155
|
+
exc.message,
|
|
156
|
+
get_request_id(),
|
|
157
|
+
)
|
|
158
|
+
await self._auth_manager._repo.update_status(account.id, AccountStatus.DEACTIVATED, reason)
|
|
159
|
+
account.status = AccountStatus.DEACTIVATED
|
|
160
|
+
account.deactivation_reason = reason
|
|
161
|
+
|
|
144
162
|
|
|
145
163
|
def _credits_snapshot(payload: UsagePayload) -> tuple[bool | None, bool | None, float | None]:
|
|
146
164
|
credits = payload.credits
|
|
@@ -171,6 +189,20 @@ def _window_minutes(limit_seconds: int | None) -> int | None:
|
|
|
171
189
|
return max(1, math.ceil(limit_seconds / 60))
|
|
172
190
|
|
|
173
191
|
|
|
174
|
-
def
|
|
175
|
-
|
|
176
|
-
|
|
192
|
+
def _now_epoch() -> int:
|
|
193
|
+
return int(utcnow().replace(tzinfo=timezone.utc).timestamp())
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _reset_at(reset_at: int | None, reset_after_seconds: int | None, now_epoch: int) -> int | None:
|
|
197
|
+
if reset_at is not None:
|
|
198
|
+
return int(reset_at)
|
|
199
|
+
if reset_after_seconds is None:
|
|
200
|
+
return None
|
|
201
|
+
return now_epoch + max(0, int(reset_after_seconds))
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
_DEACTIVATING_USAGE_STATUS_CODES = {402, 403, 404}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _should_deactivate_for_usage_error(status_code: int) -> bool:
|
|
208
|
+
return status_code in _DEACTIVATING_USAGE_STATUS_CODES
|