codex-lb 0.1.5__py3-none-any.whl → 0.3.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/__init__.py +1 -1
- app/core/auth/__init__.py +12 -1
- app/core/balancer/logic.py +44 -7
- app/core/clients/proxy.py +2 -4
- app/core/config/settings.py +4 -1
- app/core/plan_types.py +64 -0
- app/core/types.py +4 -2
- app/core/usage/__init__.py +5 -2
- app/core/usage/logs.py +12 -2
- app/core/usage/quota.py +64 -0
- app/core/usage/types.py +3 -2
- app/core/utils/sse.py +6 -2
- app/db/migrations/__init__.py +91 -0
- app/db/migrations/versions/__init__.py +1 -0
- app/db/migrations/versions/add_accounts_chatgpt_account_id.py +29 -0
- app/db/migrations/versions/add_accounts_reset_at.py +29 -0
- app/db/migrations/versions/add_dashboard_settings.py +31 -0
- app/db/migrations/versions/add_request_logs_reasoning_effort.py +21 -0
- app/db/migrations/versions/normalize_account_plan_types.py +17 -0
- app/db/models.py +33 -0
- app/db/session.py +85 -11
- app/dependencies.py +27 -9
- app/main.py +15 -6
- app/modules/accounts/auth_manager.py +121 -0
- app/modules/accounts/repository.py +14 -6
- app/modules/accounts/service.py +14 -9
- app/modules/health/api.py +5 -3
- app/modules/health/schemas.py +9 -0
- app/modules/oauth/service.py +9 -4
- app/modules/proxy/helpers.py +285 -0
- app/modules/proxy/load_balancer.py +86 -41
- app/modules/proxy/service.py +172 -318
- app/modules/proxy/sticky_repository.py +56 -0
- app/modules/request_logs/repository.py +6 -3
- app/modules/request_logs/schemas.py +2 -0
- app/modules/request_logs/service.py +12 -3
- app/modules/settings/__init__.py +1 -0
- app/modules/settings/api.py +37 -0
- app/modules/settings/repository.py +40 -0
- app/modules/settings/schemas.py +13 -0
- app/modules/settings/service.py +33 -0
- app/modules/shared/schemas.py +16 -2
- app/modules/usage/schemas.py +1 -0
- app/modules/usage/service.py +23 -6
- app/modules/{proxy/usage_updater.py → usage/updater.py} +37 -8
- app/static/7.css +73 -0
- app/static/index.css +33 -4
- app/static/index.html +51 -4
- app/static/index.js +254 -32
- {codex_lb-0.1.5.dist-info → codex_lb-0.3.0.dist-info}/METADATA +2 -2
- codex_lb-0.3.0.dist-info/RECORD +97 -0
- app/modules/proxy/auth_manager.py +0 -51
- codex_lb-0.1.5.dist-info/RECORD +0 -80
- {codex_lb-0.1.5.dist-info → codex_lb-0.3.0.dist-info}/WHEEL +0 -0
- {codex_lb-0.1.5.dist-info → codex_lb-0.3.0.dist-info}/entry_points.txt +0 -0
- {codex_lb-0.1.5.dist-info → codex_lb-0.3.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Iterable
|
|
4
|
+
|
|
5
|
+
from pydantic import ValidationError
|
|
6
|
+
|
|
7
|
+
from app.core import usage as usage_core
|
|
8
|
+
from app.core.balancer.types import UpstreamError
|
|
9
|
+
from app.core.errors import OpenAIErrorDetail, OpenAIErrorEnvelope
|
|
10
|
+
from app.core.openai.models import OpenAIError
|
|
11
|
+
from app.core.plan_types import normalize_rate_limit_plan_type
|
|
12
|
+
from app.core.usage.types import UsageWindowRow, UsageWindowSummary
|
|
13
|
+
from app.db.models import Account, AccountStatus, UsageHistory
|
|
14
|
+
from app.modules.proxy.types import (
|
|
15
|
+
CreditStatusDetailsData,
|
|
16
|
+
RateLimitStatusDetailsData,
|
|
17
|
+
RateLimitWindowSnapshotData,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
PLAN_TYPE_PRIORITY = (
|
|
21
|
+
"enterprise",
|
|
22
|
+
"business",
|
|
23
|
+
"team",
|
|
24
|
+
"pro",
|
|
25
|
+
"plus",
|
|
26
|
+
"education",
|
|
27
|
+
"edu",
|
|
28
|
+
"free_workspace",
|
|
29
|
+
"free",
|
|
30
|
+
"go",
|
|
31
|
+
"guest",
|
|
32
|
+
"quorum",
|
|
33
|
+
"k12",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _header_account_id(account_id: str | None) -> str | None:
|
|
38
|
+
if not account_id:
|
|
39
|
+
return None
|
|
40
|
+
if account_id.startswith(("email_", "local_")):
|
|
41
|
+
return None
|
|
42
|
+
return account_id
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _select_accounts_for_limits(accounts: Iterable[Account]) -> list[Account]:
|
|
46
|
+
return [account for account in accounts if account.status not in (AccountStatus.DEACTIVATED, AccountStatus.PAUSED)]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _summarize_window(
|
|
50
|
+
rows: list[UsageWindowRow],
|
|
51
|
+
account_map: dict[str, Account],
|
|
52
|
+
window: str,
|
|
53
|
+
) -> UsageWindowSummary | None:
|
|
54
|
+
if not rows:
|
|
55
|
+
return None
|
|
56
|
+
return usage_core.summarize_usage_window(rows, account_map, window)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _window_snapshot(
|
|
60
|
+
summary: UsageWindowSummary | None,
|
|
61
|
+
rows: list[UsageWindowRow],
|
|
62
|
+
window: str,
|
|
63
|
+
now_epoch: int,
|
|
64
|
+
) -> RateLimitWindowSnapshotData | None:
|
|
65
|
+
if summary is None:
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
used_percent = _normalize_used_percent(summary.used_percent, rows)
|
|
69
|
+
if used_percent is None:
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
reset_at = summary.reset_at
|
|
73
|
+
if reset_at is None:
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
window_minutes = summary.window_minutes or usage_core.default_window_minutes(window)
|
|
77
|
+
if not window_minutes:
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
limit_window_seconds = int(window_minutes * 60)
|
|
81
|
+
reset_after_seconds = max(0, int(reset_at) - now_epoch)
|
|
82
|
+
|
|
83
|
+
return RateLimitWindowSnapshotData(
|
|
84
|
+
used_percent=_percent_to_int(used_percent),
|
|
85
|
+
limit_window_seconds=limit_window_seconds,
|
|
86
|
+
reset_after_seconds=reset_after_seconds,
|
|
87
|
+
reset_at=int(reset_at),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _normalize_used_percent(
|
|
92
|
+
value: float | None,
|
|
93
|
+
rows: Iterable[UsageWindowRow],
|
|
94
|
+
) -> float | None:
|
|
95
|
+
if value is not None:
|
|
96
|
+
return value
|
|
97
|
+
values = [row.used_percent for row in rows if row.used_percent is not None]
|
|
98
|
+
if not values:
|
|
99
|
+
return None
|
|
100
|
+
return sum(values) / len(values)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _percent_to_int(value: float) -> int:
|
|
104
|
+
bounded = max(0.0, min(100.0, value))
|
|
105
|
+
return int(bounded)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _rate_limit_details(
|
|
109
|
+
primary: RateLimitWindowSnapshotData | None,
|
|
110
|
+
secondary: RateLimitWindowSnapshotData | None,
|
|
111
|
+
) -> RateLimitStatusDetailsData | None:
|
|
112
|
+
if not primary and not secondary:
|
|
113
|
+
return None
|
|
114
|
+
used_percents = [window.used_percent for window in (primary, secondary) if window]
|
|
115
|
+
limit_reached = any(used >= 100 for used in used_percents)
|
|
116
|
+
return RateLimitStatusDetailsData(
|
|
117
|
+
allowed=not limit_reached,
|
|
118
|
+
limit_reached=limit_reached,
|
|
119
|
+
primary_window=primary,
|
|
120
|
+
secondary_window=secondary,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _aggregate_credits(entries: Iterable[UsageHistory]) -> tuple[bool, bool, float] | None:
|
|
125
|
+
has_data = False
|
|
126
|
+
has_credits = False
|
|
127
|
+
unlimited = False
|
|
128
|
+
balance_total = 0.0
|
|
129
|
+
|
|
130
|
+
for entry in entries:
|
|
131
|
+
credits_has = entry.credits_has
|
|
132
|
+
credits_unlimited = entry.credits_unlimited
|
|
133
|
+
credits_balance = entry.credits_balance
|
|
134
|
+
if credits_has is None and credits_unlimited is None and credits_balance is None:
|
|
135
|
+
continue
|
|
136
|
+
has_data = True
|
|
137
|
+
if credits_has is True:
|
|
138
|
+
has_credits = True
|
|
139
|
+
if credits_unlimited is True:
|
|
140
|
+
unlimited = True
|
|
141
|
+
if credits_balance is not None and not credits_unlimited:
|
|
142
|
+
try:
|
|
143
|
+
balance_total += float(credits_balance)
|
|
144
|
+
except (TypeError, ValueError):
|
|
145
|
+
continue
|
|
146
|
+
|
|
147
|
+
if not has_data:
|
|
148
|
+
return None
|
|
149
|
+
if unlimited:
|
|
150
|
+
has_credits = True
|
|
151
|
+
return has_credits, unlimited, balance_total
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _credits_snapshot(entries: Iterable[UsageHistory]) -> CreditStatusDetailsData | None:
|
|
155
|
+
aggregate = _aggregate_credits(entries)
|
|
156
|
+
if aggregate is None:
|
|
157
|
+
return None
|
|
158
|
+
has_credits, unlimited, balance_total = aggregate
|
|
159
|
+
balance_value = str(round(balance_total, 2))
|
|
160
|
+
return CreditStatusDetailsData(
|
|
161
|
+
has_credits=has_credits,
|
|
162
|
+
unlimited=unlimited,
|
|
163
|
+
balance=balance_value,
|
|
164
|
+
approx_local_messages=None,
|
|
165
|
+
approx_cloud_messages=None,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _plan_type_for_accounts(accounts: Iterable[Account]) -> str:
|
|
170
|
+
normalized = [_normalize_plan_type(account.plan_type) for account in accounts]
|
|
171
|
+
filtered = [plan for plan in normalized if plan is not None]
|
|
172
|
+
if not filtered:
|
|
173
|
+
return "guest"
|
|
174
|
+
unique = set(filtered)
|
|
175
|
+
if len(unique) == 1:
|
|
176
|
+
return filtered[0]
|
|
177
|
+
for plan in PLAN_TYPE_PRIORITY:
|
|
178
|
+
if plan in unique:
|
|
179
|
+
return plan
|
|
180
|
+
return "guest"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _normalize_plan_type(value: str | None) -> str | None:
|
|
184
|
+
return normalize_rate_limit_plan_type(value)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _rate_limit_headers(
|
|
188
|
+
window_label: str,
|
|
189
|
+
summary: UsageWindowSummary,
|
|
190
|
+
) -> dict[str, str]:
|
|
191
|
+
used_percent = summary.used_percent
|
|
192
|
+
window_minutes = summary.window_minutes
|
|
193
|
+
if used_percent is None or window_minutes is None:
|
|
194
|
+
return {}
|
|
195
|
+
headers = {
|
|
196
|
+
f"x-codex-{window_label}-used-percent": str(float(used_percent)),
|
|
197
|
+
f"x-codex-{window_label}-window-minutes": str(int(window_minutes)),
|
|
198
|
+
}
|
|
199
|
+
reset_at = summary.reset_at
|
|
200
|
+
if reset_at is not None:
|
|
201
|
+
headers[f"x-codex-{window_label}-reset-at"] = str(int(reset_at))
|
|
202
|
+
return headers
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _credits_headers(entries: Iterable[UsageHistory]) -> dict[str, str]:
|
|
206
|
+
aggregate = _aggregate_credits(entries)
|
|
207
|
+
if aggregate is None:
|
|
208
|
+
return {}
|
|
209
|
+
has_credits, unlimited, balance_total = aggregate
|
|
210
|
+
balance_value = f"{balance_total:.2f}"
|
|
211
|
+
return {
|
|
212
|
+
"x-codex-credits-has-credits": "true" if has_credits else "false",
|
|
213
|
+
"x-codex-credits-unlimited": "true" if unlimited else "false",
|
|
214
|
+
"x-codex-credits-balance": balance_value,
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _normalize_error_code(code: str | None, error_type: str | None) -> str:
|
|
219
|
+
value = code or error_type
|
|
220
|
+
if not value:
|
|
221
|
+
return "upstream_error"
|
|
222
|
+
return value.lower()
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _parse_openai_error(payload: OpenAIErrorEnvelope) -> OpenAIError | None:
|
|
226
|
+
error = payload.get("error")
|
|
227
|
+
if not error:
|
|
228
|
+
return None
|
|
229
|
+
try:
|
|
230
|
+
return OpenAIError.model_validate(error)
|
|
231
|
+
except ValidationError:
|
|
232
|
+
if not isinstance(error, dict):
|
|
233
|
+
return None
|
|
234
|
+
return OpenAIError(
|
|
235
|
+
message=_coerce_str(error.get("message")),
|
|
236
|
+
type=_coerce_str(error.get("type")),
|
|
237
|
+
code=_coerce_str(error.get("code")),
|
|
238
|
+
param=_coerce_str(error.get("param")),
|
|
239
|
+
plan_type=_coerce_str(error.get("plan_type")),
|
|
240
|
+
resets_at=_coerce_number(error.get("resets_at")),
|
|
241
|
+
resets_in_seconds=_coerce_number(error.get("resets_in_seconds")),
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _coerce_str(value: object) -> str | None:
|
|
246
|
+
return value if isinstance(value, str) else None
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _coerce_number(value: object) -> int | float | None:
|
|
250
|
+
if isinstance(value, (int, float)):
|
|
251
|
+
return value
|
|
252
|
+
if isinstance(value, str):
|
|
253
|
+
try:
|
|
254
|
+
return float(value.strip())
|
|
255
|
+
except ValueError:
|
|
256
|
+
return None
|
|
257
|
+
return None
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _apply_error_metadata(target: OpenAIErrorDetail, error: OpenAIError | None) -> None:
|
|
261
|
+
if not error:
|
|
262
|
+
return
|
|
263
|
+
if error.plan_type is not None:
|
|
264
|
+
target["plan_type"] = error.plan_type
|
|
265
|
+
if error.resets_at is not None:
|
|
266
|
+
target["resets_at"] = error.resets_at
|
|
267
|
+
if error.resets_in_seconds is not None:
|
|
268
|
+
target["resets_in_seconds"] = error.resets_in_seconds
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _upstream_error_from_openai(error: OpenAIError | None) -> UpstreamError:
|
|
272
|
+
if not error:
|
|
273
|
+
return {}
|
|
274
|
+
data = error.model_dump(exclude_none=True)
|
|
275
|
+
payload: UpstreamError = {}
|
|
276
|
+
message = data.get("message")
|
|
277
|
+
if isinstance(message, str):
|
|
278
|
+
payload["message"] = message
|
|
279
|
+
resets_at = data.get("resets_at")
|
|
280
|
+
if isinstance(resets_at, (int, float)):
|
|
281
|
+
payload["resets_at"] = resets_at
|
|
282
|
+
resets_in_seconds = data.get("resets_in_seconds")
|
|
283
|
+
if isinstance(resets_in_seconds, (int, float)):
|
|
284
|
+
payload["resets_in_seconds"] = resets_in_seconds
|
|
285
|
+
return payload
|
|
@@ -6,21 +6,25 @@ from typing import Iterable
|
|
|
6
6
|
|
|
7
7
|
from app.core.balancer import (
|
|
8
8
|
AccountState,
|
|
9
|
+
SelectionResult,
|
|
9
10
|
handle_permanent_failure,
|
|
10
11
|
handle_quota_exceeded,
|
|
11
12
|
handle_rate_limit,
|
|
12
13
|
select_account,
|
|
13
14
|
)
|
|
14
15
|
from app.core.balancer.types import UpstreamError
|
|
15
|
-
from app.
|
|
16
|
+
from app.core.usage.quota import apply_usage_quota
|
|
17
|
+
from app.db.models import Account, UsageHistory
|
|
16
18
|
from app.modules.accounts.repository import AccountsRepository
|
|
17
|
-
from app.modules.proxy.
|
|
19
|
+
from app.modules.proxy.sticky_repository import StickySessionsRepository
|
|
18
20
|
from app.modules.usage.repository import UsageRepository
|
|
21
|
+
from app.modules.usage.updater import UsageUpdater
|
|
19
22
|
|
|
20
23
|
|
|
21
24
|
@dataclass
|
|
22
25
|
class RuntimeState:
|
|
23
26
|
reset_at: float | None = None
|
|
27
|
+
cooldown_until: float | None = None
|
|
24
28
|
last_error_at: float | None = None
|
|
25
29
|
last_selected_at: float | None = None
|
|
26
30
|
error_count: int = 0
|
|
@@ -33,13 +37,25 @@ class AccountSelection:
|
|
|
33
37
|
|
|
34
38
|
|
|
35
39
|
class LoadBalancer:
|
|
36
|
-
def __init__(
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
accounts_repo: AccountsRepository,
|
|
43
|
+
usage_repo: UsageRepository,
|
|
44
|
+
sticky_repo: StickySessionsRepository | None = None,
|
|
45
|
+
) -> None:
|
|
37
46
|
self._accounts_repo = accounts_repo
|
|
38
47
|
self._usage_repo = usage_repo
|
|
39
48
|
self._usage_updater = UsageUpdater(usage_repo, accounts_repo)
|
|
49
|
+
self._sticky_repo = sticky_repo
|
|
40
50
|
self._runtime: dict[str, RuntimeState] = {}
|
|
41
51
|
|
|
42
|
-
async def select_account(
|
|
52
|
+
async def select_account(
|
|
53
|
+
self,
|
|
54
|
+
sticky_key: str | None = None,
|
|
55
|
+
*,
|
|
56
|
+
reallocate_sticky: bool = False,
|
|
57
|
+
prefer_earlier_reset_accounts: bool = False,
|
|
58
|
+
) -> AccountSelection:
|
|
43
59
|
accounts = await self._accounts_repo.list_accounts()
|
|
44
60
|
latest_primary = await self._usage_repo.latest_by_account()
|
|
45
61
|
await self._usage_updater.refresh_accounts(accounts, latest_primary)
|
|
@@ -53,7 +69,13 @@ class LoadBalancer:
|
|
|
53
69
|
runtime=self._runtime,
|
|
54
70
|
)
|
|
55
71
|
|
|
56
|
-
result =
|
|
72
|
+
result = await self._select_with_stickiness(
|
|
73
|
+
states=states,
|
|
74
|
+
account_map=account_map,
|
|
75
|
+
sticky_key=sticky_key,
|
|
76
|
+
reallocate_sticky=reallocate_sticky,
|
|
77
|
+
prefer_earlier_reset_accounts=prefer_earlier_reset_accounts,
|
|
78
|
+
)
|
|
57
79
|
for state in states:
|
|
58
80
|
account = account_map.get(state.account_id)
|
|
59
81
|
if account:
|
|
@@ -72,6 +94,39 @@ class LoadBalancer:
|
|
|
72
94
|
return AccountSelection(account=None, error_message=result.error_message)
|
|
73
95
|
return AccountSelection(account=selected, error_message=None)
|
|
74
96
|
|
|
97
|
+
async def _select_with_stickiness(
|
|
98
|
+
self,
|
|
99
|
+
*,
|
|
100
|
+
states: list[AccountState],
|
|
101
|
+
account_map: dict[str, Account],
|
|
102
|
+
sticky_key: str | None,
|
|
103
|
+
reallocate_sticky: bool,
|
|
104
|
+
prefer_earlier_reset_accounts: bool,
|
|
105
|
+
) -> SelectionResult:
|
|
106
|
+
if not sticky_key or not self._sticky_repo:
|
|
107
|
+
return select_account(states, prefer_earlier_reset=prefer_earlier_reset_accounts)
|
|
108
|
+
|
|
109
|
+
if reallocate_sticky:
|
|
110
|
+
chosen = select_account(states, prefer_earlier_reset=prefer_earlier_reset_accounts)
|
|
111
|
+
if chosen.account is not None and chosen.account.account_id in account_map:
|
|
112
|
+
await self._sticky_repo.upsert(sticky_key, chosen.account.account_id)
|
|
113
|
+
return chosen
|
|
114
|
+
|
|
115
|
+
existing = await self._sticky_repo.get_account_id(sticky_key)
|
|
116
|
+
if existing:
|
|
117
|
+
pinned = next((state for state in states if state.account_id == existing), None)
|
|
118
|
+
if pinned is None:
|
|
119
|
+
await self._sticky_repo.delete(sticky_key)
|
|
120
|
+
else:
|
|
121
|
+
pinned_result = select_account([pinned], prefer_earlier_reset=prefer_earlier_reset_accounts)
|
|
122
|
+
if pinned_result.account is not None:
|
|
123
|
+
return pinned_result
|
|
124
|
+
|
|
125
|
+
chosen = select_account(states, prefer_earlier_reset=prefer_earlier_reset_accounts)
|
|
126
|
+
if chosen.account is not None and chosen.account.account_id in account_map:
|
|
127
|
+
await self._sticky_repo.upsert(sticky_key, chosen.account.account_id)
|
|
128
|
+
return chosen
|
|
129
|
+
|
|
75
130
|
async def mark_rate_limit(self, account: Account, error: UpstreamError) -> None:
|
|
76
131
|
state = self._state_for(account)
|
|
77
132
|
handle_rate_limit(state, error)
|
|
@@ -100,6 +155,9 @@ class LoadBalancer:
|
|
|
100
155
|
status=account.status,
|
|
101
156
|
used_percent=None,
|
|
102
157
|
reset_at=runtime.reset_at,
|
|
158
|
+
cooldown_until=runtime.cooldown_until,
|
|
159
|
+
secondary_used_percent=None,
|
|
160
|
+
secondary_reset_at=None,
|
|
103
161
|
last_error_at=runtime.last_error_at,
|
|
104
162
|
last_selected_at=runtime.last_selected_at,
|
|
105
163
|
error_count=runtime.error_count,
|
|
@@ -109,17 +167,25 @@ class LoadBalancer:
|
|
|
109
167
|
async def _sync_state(self, account: Account, state: AccountState) -> None:
|
|
110
168
|
runtime = self._runtime.setdefault(account.id, RuntimeState())
|
|
111
169
|
runtime.reset_at = state.reset_at
|
|
170
|
+
runtime.cooldown_until = state.cooldown_until
|
|
112
171
|
runtime.last_error_at = state.last_error_at
|
|
113
172
|
runtime.error_count = state.error_count
|
|
114
173
|
|
|
115
|
-
|
|
174
|
+
reset_at_int = int(state.reset_at) if state.reset_at else None
|
|
175
|
+
status_changed = account.status != state.status
|
|
176
|
+
reason_changed = account.deactivation_reason != state.deactivation_reason
|
|
177
|
+
reset_changed = account.reset_at != reset_at_int
|
|
178
|
+
|
|
179
|
+
if status_changed or reason_changed or reset_changed:
|
|
116
180
|
await self._accounts_repo.update_status(
|
|
117
181
|
account.id,
|
|
118
182
|
state.status,
|
|
119
183
|
state.deactivation_reason,
|
|
184
|
+
reset_at_int,
|
|
120
185
|
)
|
|
121
186
|
account.status = state.status
|
|
122
187
|
account.deactivation_reason = state.deactivation_reason
|
|
188
|
+
account.reset_at = reset_at_int
|
|
123
189
|
|
|
124
190
|
|
|
125
191
|
def _build_states(
|
|
@@ -152,13 +218,22 @@ def _state_from_account(
|
|
|
152
218
|
runtime: RuntimeState,
|
|
153
219
|
) -> AccountState:
|
|
154
220
|
primary_used = primary_entry.used_percent if primary_entry else None
|
|
221
|
+
primary_reset = primary_entry.reset_at if primary_entry else None
|
|
222
|
+
primary_window_minutes = primary_entry.window_minutes if primary_entry else None
|
|
155
223
|
secondary_used = secondary_entry.used_percent if secondary_entry else None
|
|
156
224
|
secondary_reset = secondary_entry.reset_at if secondary_entry else None
|
|
157
225
|
|
|
158
|
-
|
|
226
|
+
# Use account.reset_at from DB as the authoritative source for runtime reset
|
|
227
|
+
# This survives across requests since LoadBalancer is instantiated per-request
|
|
228
|
+
db_reset_at = float(account.reset_at) if account.reset_at else None
|
|
229
|
+
effective_runtime_reset = db_reset_at or runtime.reset_at
|
|
230
|
+
|
|
231
|
+
status, used_percent, reset_at = apply_usage_quota(
|
|
159
232
|
status=account.status,
|
|
160
233
|
primary_used=primary_used,
|
|
161
|
-
|
|
234
|
+
primary_reset=primary_reset,
|
|
235
|
+
primary_window_minutes=primary_window_minutes,
|
|
236
|
+
runtime_reset=effective_runtime_reset,
|
|
162
237
|
secondary_used=secondary_used,
|
|
163
238
|
secondary_reset=secondary_reset,
|
|
164
239
|
)
|
|
@@ -168,41 +243,11 @@ def _state_from_account(
|
|
|
168
243
|
status=status,
|
|
169
244
|
used_percent=used_percent,
|
|
170
245
|
reset_at=reset_at,
|
|
246
|
+
cooldown_until=runtime.cooldown_until,
|
|
247
|
+
secondary_used_percent=secondary_used,
|
|
248
|
+
secondary_reset_at=secondary_reset,
|
|
171
249
|
last_error_at=runtime.last_error_at,
|
|
172
250
|
last_selected_at=runtime.last_selected_at,
|
|
173
251
|
error_count=runtime.error_count,
|
|
174
252
|
deactivation_reason=account.deactivation_reason,
|
|
175
253
|
)
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
def _apply_secondary_quota(
|
|
179
|
-
*,
|
|
180
|
-
status: AccountStatus,
|
|
181
|
-
primary_used: float | None,
|
|
182
|
-
runtime_reset: float | None,
|
|
183
|
-
secondary_used: float | None,
|
|
184
|
-
secondary_reset: int | None,
|
|
185
|
-
) -> tuple[AccountStatus, float | None, float | None]:
|
|
186
|
-
used_percent = primary_used
|
|
187
|
-
reset_at = runtime_reset
|
|
188
|
-
|
|
189
|
-
if status in (AccountStatus.DEACTIVATED, AccountStatus.PAUSED):
|
|
190
|
-
return status, used_percent, reset_at
|
|
191
|
-
|
|
192
|
-
if secondary_used is None:
|
|
193
|
-
if status == AccountStatus.QUOTA_EXCEEDED and secondary_reset is not None:
|
|
194
|
-
reset_at = secondary_reset
|
|
195
|
-
return status, used_percent, reset_at
|
|
196
|
-
|
|
197
|
-
if secondary_used >= 100.0:
|
|
198
|
-
status = AccountStatus.QUOTA_EXCEEDED
|
|
199
|
-
used_percent = 100.0
|
|
200
|
-
if secondary_reset is not None:
|
|
201
|
-
reset_at = secondary_reset
|
|
202
|
-
return status, used_percent, reset_at
|
|
203
|
-
|
|
204
|
-
if status == AccountStatus.QUOTA_EXCEEDED:
|
|
205
|
-
status = AccountStatus.ACTIVE
|
|
206
|
-
reset_at = None
|
|
207
|
-
|
|
208
|
-
return status, used_percent, reset_at
|