codex-lb 0.1.4__py3-none-any.whl → 0.2.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 +2 -1
- app/core/balancer/logic.py +16 -13
- app/core/clients/proxy.py +2 -4
- app/core/config/settings.py +2 -1
- app/core/plan_types.py +64 -0
- app/core/types.py +4 -2
- app/core/usage/__init__.py +3 -2
- app/core/usage/quota.py +58 -0
- app/core/utils/retry.py +14 -0
- app/core/utils/sse.py +6 -2
- app/db/migrations/__init__.py +80 -0
- app/db/migrations/versions/__init__.py +1 -0
- app/db/migrations/versions/normalize_account_plan_types.py +17 -0
- app/db/session.py +14 -0
- app/dependencies.py +0 -8
- app/main.py +4 -4
- app/modules/{proxy → accounts}/auth_manager.py +33 -4
- app/modules/accounts/repository.py +3 -3
- app/modules/accounts/service.py +10 -7
- app/modules/health/api.py +5 -3
- app/modules/health/schemas.py +9 -0
- app/modules/oauth/service.py +5 -1
- app/modules/proxy/helpers.py +285 -0
- app/modules/proxy/load_balancer.py +13 -37
- app/modules/proxy/service.py +37 -307
- app/modules/request_logs/service.py +5 -3
- app/modules/usage/service.py +7 -6
- app/modules/{proxy/usage_updater.py → usage/updater.py} +1 -1
- app/static/index.js +26 -18
- {codex_lb-0.1.4.dist-info → codex_lb-0.2.0.dist-info}/METADATA +1 -1
- {codex_lb-0.1.4.dist-info → codex_lb-0.2.0.dist-info}/RECORD +35 -28
- {codex_lb-0.1.4.dist-info → codex_lb-0.2.0.dist-info}/WHEEL +0 -0
- {codex_lb-0.1.4.dist-info → codex_lb-0.2.0.dist-info}/entry_points.txt +0 -0
- {codex_lb-0.1.4.dist-info → codex_lb-0.2.0.dist-info}/licenses/LICENSE +0 -0
app/modules/accounts/service.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from datetime import datetime, timedelta, timezone
|
|
4
|
+
from typing import cast
|
|
4
5
|
|
|
5
6
|
from app.core import usage as usage_core
|
|
6
7
|
from app.core.auth import (
|
|
@@ -12,7 +13,8 @@ from app.core.auth import (
|
|
|
12
13
|
parse_auth_json,
|
|
13
14
|
)
|
|
14
15
|
from app.core.crypto import TokenEncryptor
|
|
15
|
-
from app.core.
|
|
16
|
+
from app.core.plan_types import coerce_account_plan_type
|
|
17
|
+
from app.core.usage.logs import RequestLogLike, cost_from_log
|
|
16
18
|
from app.core.utils.time import from_epoch_seconds, to_utc_naive, utcnow
|
|
17
19
|
from app.db.models import Account, AccountStatus, UsageHistory
|
|
18
20
|
from app.modules.accounts.repository import AccountsRepository
|
|
@@ -23,9 +25,9 @@ from app.modules.accounts.schemas import (
|
|
|
23
25
|
AccountTokenStatus,
|
|
24
26
|
AccountUsage,
|
|
25
27
|
)
|
|
26
|
-
from app.modules.proxy.usage_updater import UsageUpdater
|
|
27
28
|
from app.modules.request_logs.repository import RequestLogsRepository
|
|
28
29
|
from app.modules.usage.repository import UsageRepository
|
|
30
|
+
from app.modules.usage.updater import UsageUpdater
|
|
29
31
|
|
|
30
32
|
|
|
31
33
|
class AccountsService:
|
|
@@ -64,7 +66,7 @@ class AccountsService:
|
|
|
64
66
|
claims = claims_from_auth(auth)
|
|
65
67
|
|
|
66
68
|
email = claims.email or DEFAULT_EMAIL
|
|
67
|
-
plan_type = claims.plan_type
|
|
69
|
+
plan_type = coerce_account_plan_type(claims.plan_type, DEFAULT_PLAN)
|
|
68
70
|
account_id = claims.account_id or fallback_account_id(email)
|
|
69
71
|
last_refresh = to_utc_naive(auth.last_refresh_at) if auth.last_refresh_at else utcnow()
|
|
70
72
|
|
|
@@ -107,6 +109,7 @@ class AccountsService:
|
|
|
107
109
|
secondary_usage: UsageHistory | None,
|
|
108
110
|
cost_usd_24h: float | None,
|
|
109
111
|
) -> AccountSummary:
|
|
112
|
+
plan_type = coerce_account_plan_type(account.plan_type, DEFAULT_PLAN)
|
|
110
113
|
auth_status = self._build_auth_status(account)
|
|
111
114
|
primary_used_percent = _normalize_used_percent(primary_usage) or 0.0
|
|
112
115
|
secondary_used_percent = _normalize_used_percent(secondary_usage) or 0.0
|
|
@@ -114,8 +117,8 @@ class AccountsService:
|
|
|
114
117
|
secondary_remaining_percent = usage_core.remaining_percent_from_used(secondary_used_percent) or 0.0
|
|
115
118
|
reset_at_primary = from_epoch_seconds(primary_usage.reset_at) if primary_usage is not None else None
|
|
116
119
|
reset_at_secondary = from_epoch_seconds(secondary_usage.reset_at) if secondary_usage is not None else None
|
|
117
|
-
capacity_primary = usage_core.capacity_for_plan(
|
|
118
|
-
capacity_secondary = usage_core.capacity_for_plan(
|
|
120
|
+
capacity_primary = usage_core.capacity_for_plan(plan_type, "primary")
|
|
121
|
+
capacity_secondary = usage_core.capacity_for_plan(plan_type, "secondary")
|
|
119
122
|
remaining_credits_primary = usage_core.remaining_credits_from_percent(
|
|
120
123
|
primary_used_percent,
|
|
121
124
|
capacity_primary,
|
|
@@ -128,7 +131,7 @@ class AccountsService:
|
|
|
128
131
|
account_id=account.id,
|
|
129
132
|
email=account.email,
|
|
130
133
|
display_name=account.email,
|
|
131
|
-
plan_type=
|
|
134
|
+
plan_type=plan_type,
|
|
132
135
|
status=account.status.value,
|
|
133
136
|
usage=AccountUsage(
|
|
134
137
|
primary_remaining_percent=primary_remaining_percent,
|
|
@@ -186,7 +189,7 @@ class AccountsService:
|
|
|
186
189
|
logs = await self._logs_repo.list_since(since)
|
|
187
190
|
totals: dict[str, float] = {}
|
|
188
191
|
for log in logs:
|
|
189
|
-
cost = cost_from_log(log)
|
|
192
|
+
cost = cost_from_log(cast(RequestLogLike, log))
|
|
190
193
|
if cost is None:
|
|
191
194
|
continue
|
|
192
195
|
totals[log.account_id] = totals.get(log.account_id, 0.0) + cost
|
app/modules/health/api.py
CHANGED
|
@@ -2,9 +2,11 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from fastapi import APIRouter
|
|
4
4
|
|
|
5
|
+
from app.modules.health.schemas import HealthResponse
|
|
6
|
+
|
|
5
7
|
router = APIRouter(tags=["health"])
|
|
6
8
|
|
|
7
9
|
|
|
8
|
-
@router.get("/health")
|
|
9
|
-
async def health_check() ->
|
|
10
|
-
return
|
|
10
|
+
@router.get("/health", response_model=HealthResponse)
|
|
11
|
+
async def health_check() -> HealthResponse:
|
|
12
|
+
return HealthResponse(status="ok")
|
app/modules/oauth/service.py
CHANGED
|
@@ -28,6 +28,7 @@ from app.core.clients.oauth import (
|
|
|
28
28
|
)
|
|
29
29
|
from app.core.config.settings import get_settings
|
|
30
30
|
from app.core.crypto import TokenEncryptor
|
|
31
|
+
from app.core.plan_types import coerce_account_plan_type
|
|
31
32
|
from app.core.utils.time import utcnow
|
|
32
33
|
from app.db.models import Account, AccountStatus
|
|
33
34
|
from app.modules.accounts.repository import AccountsRepository
|
|
@@ -295,7 +296,10 @@ class OauthService:
|
|
|
295
296
|
auth_claims = claims.auth or OpenAIAuthClaims()
|
|
296
297
|
account_id = auth_claims.chatgpt_account_id or claims.chatgpt_account_id
|
|
297
298
|
email = claims.email or DEFAULT_EMAIL
|
|
298
|
-
plan_type =
|
|
299
|
+
plan_type = coerce_account_plan_type(
|
|
300
|
+
auth_claims.chatgpt_plan_type or claims.chatgpt_plan_type,
|
|
301
|
+
DEFAULT_PLAN,
|
|
302
|
+
)
|
|
299
303
|
account_id = account_id or fallback_account_id(email)
|
|
300
304
|
|
|
301
305
|
account = Account(
|
|
@@ -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
|
|
@@ -12,15 +12,17 @@ from app.core.balancer import (
|
|
|
12
12
|
select_account,
|
|
13
13
|
)
|
|
14
14
|
from app.core.balancer.types import UpstreamError
|
|
15
|
-
from app.
|
|
15
|
+
from app.core.usage.quota import apply_usage_quota
|
|
16
|
+
from app.db.models import Account, UsageHistory
|
|
16
17
|
from app.modules.accounts.repository import AccountsRepository
|
|
17
|
-
from app.modules.proxy.usage_updater import UsageUpdater
|
|
18
18
|
from app.modules.usage.repository import UsageRepository
|
|
19
|
+
from app.modules.usage.updater import UsageUpdater
|
|
19
20
|
|
|
20
21
|
|
|
21
22
|
@dataclass
|
|
22
23
|
class RuntimeState:
|
|
23
|
-
reset_at:
|
|
24
|
+
reset_at: float | None = None
|
|
25
|
+
cooldown_until: float | None = None
|
|
24
26
|
last_error_at: float | None = None
|
|
25
27
|
last_selected_at: float | None = None
|
|
26
28
|
error_count: int = 0
|
|
@@ -100,6 +102,7 @@ class LoadBalancer:
|
|
|
100
102
|
status=account.status,
|
|
101
103
|
used_percent=None,
|
|
102
104
|
reset_at=runtime.reset_at,
|
|
105
|
+
cooldown_until=runtime.cooldown_until,
|
|
103
106
|
last_error_at=runtime.last_error_at,
|
|
104
107
|
last_selected_at=runtime.last_selected_at,
|
|
105
108
|
error_count=runtime.error_count,
|
|
@@ -109,6 +112,7 @@ class LoadBalancer:
|
|
|
109
112
|
async def _sync_state(self, account: Account, state: AccountState) -> None:
|
|
110
113
|
runtime = self._runtime.setdefault(account.id, RuntimeState())
|
|
111
114
|
runtime.reset_at = state.reset_at
|
|
115
|
+
runtime.cooldown_until = state.cooldown_until
|
|
112
116
|
runtime.last_error_at = state.last_error_at
|
|
113
117
|
runtime.error_count = state.error_count
|
|
114
118
|
|
|
@@ -152,12 +156,16 @@ def _state_from_account(
|
|
|
152
156
|
runtime: RuntimeState,
|
|
153
157
|
) -> AccountState:
|
|
154
158
|
primary_used = primary_entry.used_percent if primary_entry else None
|
|
159
|
+
primary_reset = primary_entry.reset_at if primary_entry else None
|
|
160
|
+
primary_window_minutes = primary_entry.window_minutes if primary_entry else None
|
|
155
161
|
secondary_used = secondary_entry.used_percent if secondary_entry else None
|
|
156
162
|
secondary_reset = secondary_entry.reset_at if secondary_entry else None
|
|
157
163
|
|
|
158
|
-
status, used_percent, reset_at =
|
|
164
|
+
status, used_percent, reset_at = apply_usage_quota(
|
|
159
165
|
status=account.status,
|
|
160
166
|
primary_used=primary_used,
|
|
167
|
+
primary_reset=primary_reset,
|
|
168
|
+
primary_window_minutes=primary_window_minutes,
|
|
161
169
|
runtime_reset=runtime.reset_at,
|
|
162
170
|
secondary_used=secondary_used,
|
|
163
171
|
secondary_reset=secondary_reset,
|
|
@@ -168,41 +176,9 @@ def _state_from_account(
|
|
|
168
176
|
status=status,
|
|
169
177
|
used_percent=used_percent,
|
|
170
178
|
reset_at=reset_at,
|
|
179
|
+
cooldown_until=runtime.cooldown_until,
|
|
171
180
|
last_error_at=runtime.last_error_at,
|
|
172
181
|
last_selected_at=runtime.last_selected_at,
|
|
173
182
|
error_count=runtime.error_count,
|
|
174
183
|
deactivation_reason=account.deactivation_reason,
|
|
175
184
|
)
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
def _apply_secondary_quota(
|
|
179
|
-
*,
|
|
180
|
-
status: AccountStatus,
|
|
181
|
-
primary_used: float | None,
|
|
182
|
-
runtime_reset: int | None,
|
|
183
|
-
secondary_used: float | None,
|
|
184
|
-
secondary_reset: int | None,
|
|
185
|
-
) -> tuple[AccountStatus, float | None, int | 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
|