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,122 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
|
|
4
|
+
<head>
|
|
5
|
+
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
|
6
|
+
|
|
7
|
+
<title>Sign into Codex LB</title>
|
|
8
|
+
<link rel="icon"
|
|
9
|
+
href="data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none" viewBox="0 0 32 32"%3E%3Cpath stroke="%23000" stroke-linecap="round" stroke-width="2.484" d="M22.356 19.797H17.17M9.662 12.29l1.979 3.576a.511.511 0 0 1-.005.504l-1.974 3.409M30.758 16c0 8.15-6.607 14.758-14.758 14.758-8.15 0-14.758-6.607-14.758-14.758C1.242 7.85 7.85 1.242 16 1.242c8.15 0 14.758 6.608 14.758 14.758Z"/%3E%3C/svg%3E"
|
|
10
|
+
type="image/svg+xml">
|
|
11
|
+
<style>
|
|
12
|
+
.container {
|
|
13
|
+
margin: auto;
|
|
14
|
+
height: 100%;
|
|
15
|
+
display: flex;
|
|
16
|
+
align-items: center;
|
|
17
|
+
justify-content: center;
|
|
18
|
+
position: relative;
|
|
19
|
+
background: white;
|
|
20
|
+
|
|
21
|
+
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.inner-container {
|
|
25
|
+
width: 400px;
|
|
26
|
+
flex-direction: column;
|
|
27
|
+
justify-content: flex-start;
|
|
28
|
+
align-items: center;
|
|
29
|
+
gap: 20px;
|
|
30
|
+
display: inline-flex;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.content {
|
|
34
|
+
align-self: stretch;
|
|
35
|
+
flex-direction: column;
|
|
36
|
+
justify-content: flex-start;
|
|
37
|
+
align-items: center;
|
|
38
|
+
gap: 20px;
|
|
39
|
+
display: flex;
|
|
40
|
+
margin-top: 15vh;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.title {
|
|
44
|
+
text-align: center;
|
|
45
|
+
color: var(--text-primary, #0D0D0D);
|
|
46
|
+
font-size: 32px;
|
|
47
|
+
font-weight: 400;
|
|
48
|
+
line-height: 40px;
|
|
49
|
+
word-wrap: break-word;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.setup-description {
|
|
53
|
+
align-self: stretch;
|
|
54
|
+
color: var(--text-secondary, #5D5D5D);
|
|
55
|
+
font-size: 14px;
|
|
56
|
+
font-weight: 400;
|
|
57
|
+
line-height: 20px;
|
|
58
|
+
word-wrap: break-word;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.logo {
|
|
62
|
+
display: flex;
|
|
63
|
+
align-items: center;
|
|
64
|
+
justify-content: center;
|
|
65
|
+
width: 4rem;
|
|
66
|
+
height: 4rem;
|
|
67
|
+
border-radius: 16px;
|
|
68
|
+
border: .5px solid rgba(0, 0, 0, 0.1);
|
|
69
|
+
box-shadow: rgba(0, 0, 0, 0.1) 0px 4px 16px 0px;
|
|
70
|
+
box-sizing: border-box;
|
|
71
|
+
background-color: rgb(255, 255, 255);
|
|
72
|
+
}
|
|
73
|
+
</style>
|
|
74
|
+
</head>
|
|
75
|
+
|
|
76
|
+
<body>
|
|
77
|
+
<div class="container">
|
|
78
|
+
<div class="inner-container">
|
|
79
|
+
<div class="content">
|
|
80
|
+
<div class="logo">
|
|
81
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none" viewBox="0 0 32 32">
|
|
82
|
+
<path stroke="#000" stroke-linecap="round" stroke-width="2.484"
|
|
83
|
+
d="M22.356 19.797H17.17M9.662 12.29l1.979 3.576a.511.511 0 0 1-.005.504l-1.974 3.409M30.758 16c0 8.15-6.607 14.758-14.758 14.758-8.15 0-14.758-6.607-14.758-14.758C1.242 7.85 7.85 1.242 16 1.242c8.15 0 14.758 6.608 14.758 14.758Z">
|
|
84
|
+
</path>
|
|
85
|
+
</svg>
|
|
86
|
+
</div>
|
|
87
|
+
<div class="title">Signed in to Codex LB</div>
|
|
88
|
+
</div>
|
|
89
|
+
<div class="close-box" style="display: flex;">
|
|
90
|
+
<div class="setup-description" id="close-message">
|
|
91
|
+
Closing in <span id="close-countdown">3</span>s...
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
<script>
|
|
97
|
+
(() => {
|
|
98
|
+
const countdown = document.getElementById("close-countdown");
|
|
99
|
+
const message = document.getElementById("close-message");
|
|
100
|
+
let remaining = 3;
|
|
101
|
+
const tick = () => {
|
|
102
|
+
if (remaining <= 0) {
|
|
103
|
+
if (message) {
|
|
104
|
+
message.textContent = "Closing...";
|
|
105
|
+
}
|
|
106
|
+
window.close();
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (countdown) {
|
|
110
|
+
countdown.textContent = String(remaining);
|
|
111
|
+
} else if (message) {
|
|
112
|
+
message.textContent = `Closing in ${remaining}s...`;
|
|
113
|
+
}
|
|
114
|
+
remaining -= 1;
|
|
115
|
+
window.setTimeout(tick, 1000);
|
|
116
|
+
};
|
|
117
|
+
tick();
|
|
118
|
+
})();
|
|
119
|
+
</script>
|
|
120
|
+
</body>
|
|
121
|
+
|
|
122
|
+
</html>
|
|
File without changes
|
app/modules/proxy/api.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterator
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Body, Depends, Request, Response
|
|
6
|
+
from fastapi.responses import JSONResponse, StreamingResponse
|
|
7
|
+
|
|
8
|
+
from app.core.clients.proxy import ProxyResponseError
|
|
9
|
+
from app.core.errors import openai_error
|
|
10
|
+
from app.core.openai.requests import ResponsesCompactRequest, ResponsesRequest
|
|
11
|
+
from app.dependencies import ProxyContext, get_proxy_context
|
|
12
|
+
from app.modules.proxy.schemas import RateLimitStatusPayload
|
|
13
|
+
|
|
14
|
+
router = APIRouter(prefix="/backend-api/codex", tags=["proxy"])
|
|
15
|
+
usage_router = APIRouter(tags=["proxy"])
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@router.post("/responses")
|
|
19
|
+
async def responses(
|
|
20
|
+
request: Request,
|
|
21
|
+
payload: ResponsesRequest = Body(...),
|
|
22
|
+
context: ProxyContext = Depends(get_proxy_context),
|
|
23
|
+
) -> Response:
|
|
24
|
+
rate_limit_headers = await context.service.rate_limit_headers()
|
|
25
|
+
stream = context.service.stream_responses(
|
|
26
|
+
payload,
|
|
27
|
+
request.headers,
|
|
28
|
+
propagate_http_errors=True,
|
|
29
|
+
)
|
|
30
|
+
try:
|
|
31
|
+
first = await stream.__anext__()
|
|
32
|
+
except StopAsyncIteration:
|
|
33
|
+
return StreamingResponse(
|
|
34
|
+
_prepend_first(None, stream),
|
|
35
|
+
media_type="text/event-stream",
|
|
36
|
+
headers={"Cache-Control": "no-cache", **rate_limit_headers},
|
|
37
|
+
)
|
|
38
|
+
except ProxyResponseError as exc:
|
|
39
|
+
return JSONResponse(status_code=exc.status_code, content=exc.payload, headers=rate_limit_headers)
|
|
40
|
+
return StreamingResponse(
|
|
41
|
+
_prepend_first(first, stream),
|
|
42
|
+
media_type="text/event-stream",
|
|
43
|
+
headers={"Cache-Control": "no-cache", **rate_limit_headers},
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@router.post("/responses/compact")
|
|
48
|
+
async def responses_compact(
|
|
49
|
+
request: Request,
|
|
50
|
+
payload: ResponsesCompactRequest = Body(...),
|
|
51
|
+
context: ProxyContext = Depends(get_proxy_context),
|
|
52
|
+
) -> JSONResponse:
|
|
53
|
+
rate_limit_headers = await context.service.rate_limit_headers()
|
|
54
|
+
try:
|
|
55
|
+
result = await context.service.compact_responses(payload, request.headers)
|
|
56
|
+
except NotImplementedError:
|
|
57
|
+
error = openai_error("not_implemented", "responses/compact is not implemented")
|
|
58
|
+
return JSONResponse(status_code=501, content=error, headers=rate_limit_headers)
|
|
59
|
+
except ProxyResponseError as exc:
|
|
60
|
+
return JSONResponse(status_code=exc.status_code, content=exc.payload, headers=rate_limit_headers)
|
|
61
|
+
return JSONResponse(content=result.model_dump(exclude_none=True), headers=rate_limit_headers)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@usage_router.get("/api/codex/usage", response_model=RateLimitStatusPayload)
|
|
65
|
+
async def codex_usage(
|
|
66
|
+
context: ProxyContext = Depends(get_proxy_context),
|
|
67
|
+
) -> RateLimitStatusPayload:
|
|
68
|
+
payload = await context.service.get_rate_limit_payload()
|
|
69
|
+
return RateLimitStatusPayload.from_data(payload)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
async def _prepend_first(first: str | None, stream: AsyncIterator[str]) -> AsyncIterator[str]:
|
|
73
|
+
if first is not None:
|
|
74
|
+
yield first
|
|
75
|
+
async for line in stream:
|
|
76
|
+
yield line
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from app.core.auth.refresh import RefreshError, refresh_access_token, should_refresh
|
|
4
|
+
from app.core.balancer import PERMANENT_FAILURE_CODES
|
|
5
|
+
from app.core.crypto import TokenEncryptor
|
|
6
|
+
from app.core.utils.time import utcnow
|
|
7
|
+
from app.db.models import Account, AccountStatus
|
|
8
|
+
from app.modules.accounts.repository import AccountsRepository
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AuthManager:
|
|
12
|
+
def __init__(self, repo: AccountsRepository) -> None:
|
|
13
|
+
self._repo = repo
|
|
14
|
+
self._encryptor = TokenEncryptor()
|
|
15
|
+
|
|
16
|
+
async def ensure_fresh(self, account: Account, *, force: bool = False) -> Account:
|
|
17
|
+
if force or should_refresh(account.last_refresh):
|
|
18
|
+
return await self.refresh_account(account)
|
|
19
|
+
return account
|
|
20
|
+
|
|
21
|
+
async def refresh_account(self, account: Account) -> Account:
|
|
22
|
+
refresh_token = self._encryptor.decrypt(account.refresh_token_encrypted)
|
|
23
|
+
try:
|
|
24
|
+
result = await refresh_access_token(refresh_token)
|
|
25
|
+
except RefreshError as exc:
|
|
26
|
+
if exc.is_permanent:
|
|
27
|
+
reason = PERMANENT_FAILURE_CODES.get(exc.code, exc.message)
|
|
28
|
+
await self._repo.update_status(account.id, AccountStatus.DEACTIVATED, reason)
|
|
29
|
+
account.status = AccountStatus.DEACTIVATED
|
|
30
|
+
account.deactivation_reason = reason
|
|
31
|
+
raise
|
|
32
|
+
|
|
33
|
+
account.access_token_encrypted = self._encryptor.encrypt(result.access_token)
|
|
34
|
+
account.refresh_token_encrypted = self._encryptor.encrypt(result.refresh_token)
|
|
35
|
+
account.id_token_encrypted = self._encryptor.encrypt(result.id_token)
|
|
36
|
+
account.last_refresh = utcnow()
|
|
37
|
+
if result.plan_type:
|
|
38
|
+
account.plan_type = result.plan_type
|
|
39
|
+
if result.email:
|
|
40
|
+
account.email = result.email
|
|
41
|
+
|
|
42
|
+
await self._repo.update_tokens(
|
|
43
|
+
account.id,
|
|
44
|
+
access_token_encrypted=account.access_token_encrypted,
|
|
45
|
+
refresh_token_encrypted=account.refresh_token_encrypted,
|
|
46
|
+
id_token_encrypted=account.id_token_encrypted,
|
|
47
|
+
last_refresh=account.last_refresh,
|
|
48
|
+
plan_type=account.plan_type,
|
|
49
|
+
email=account.email,
|
|
50
|
+
)
|
|
51
|
+
return account
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Iterable
|
|
6
|
+
|
|
7
|
+
from app.core.balancer import (
|
|
8
|
+
AccountState,
|
|
9
|
+
handle_permanent_failure,
|
|
10
|
+
handle_quota_exceeded,
|
|
11
|
+
handle_rate_limit,
|
|
12
|
+
select_account,
|
|
13
|
+
)
|
|
14
|
+
from app.core.balancer.types import UpstreamError
|
|
15
|
+
from app.db.models import Account, AccountStatus, UsageHistory
|
|
16
|
+
from app.modules.accounts.repository import AccountsRepository
|
|
17
|
+
from app.modules.proxy.usage_updater import UsageUpdater
|
|
18
|
+
from app.modules.usage.repository import UsageRepository
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class RuntimeState:
|
|
23
|
+
reset_at: int | None = None
|
|
24
|
+
last_error_at: float | None = None
|
|
25
|
+
last_selected_at: float | None = None
|
|
26
|
+
error_count: int = 0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class AccountSelection:
|
|
31
|
+
account: Account | None
|
|
32
|
+
error_message: str | None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class LoadBalancer:
|
|
36
|
+
def __init__(self, accounts_repo: AccountsRepository, usage_repo: UsageRepository) -> None:
|
|
37
|
+
self._accounts_repo = accounts_repo
|
|
38
|
+
self._usage_repo = usage_repo
|
|
39
|
+
self._usage_updater = UsageUpdater(usage_repo, accounts_repo)
|
|
40
|
+
self._runtime: dict[str, RuntimeState] = {}
|
|
41
|
+
|
|
42
|
+
async def select_account(self) -> AccountSelection:
|
|
43
|
+
accounts = await self._accounts_repo.list_accounts()
|
|
44
|
+
latest_primary = await self._usage_repo.latest_by_account()
|
|
45
|
+
await self._usage_updater.refresh_accounts(accounts, latest_primary)
|
|
46
|
+
latest_primary = await self._usage_repo.latest_by_account()
|
|
47
|
+
latest_secondary = await self._usage_repo.latest_by_account(window="secondary")
|
|
48
|
+
|
|
49
|
+
states, account_map = _build_states(
|
|
50
|
+
accounts=accounts,
|
|
51
|
+
latest_primary=latest_primary,
|
|
52
|
+
latest_secondary=latest_secondary,
|
|
53
|
+
runtime=self._runtime,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
result = select_account(states)
|
|
57
|
+
for state in states:
|
|
58
|
+
account = account_map.get(state.account_id)
|
|
59
|
+
if account:
|
|
60
|
+
await self._sync_state(account, state)
|
|
61
|
+
|
|
62
|
+
if result.account is None:
|
|
63
|
+
return AccountSelection(account=None, error_message=result.error_message)
|
|
64
|
+
|
|
65
|
+
selected = account_map.get(result.account.account_id)
|
|
66
|
+
if selected:
|
|
67
|
+
selected.status = result.account.status
|
|
68
|
+
selected.deactivation_reason = result.account.deactivation_reason
|
|
69
|
+
runtime = self._runtime.setdefault(selected.id, RuntimeState())
|
|
70
|
+
runtime.last_selected_at = time.time()
|
|
71
|
+
if selected is None:
|
|
72
|
+
return AccountSelection(account=None, error_message=result.error_message)
|
|
73
|
+
return AccountSelection(account=selected, error_message=None)
|
|
74
|
+
|
|
75
|
+
async def mark_rate_limit(self, account: Account, error: UpstreamError) -> None:
|
|
76
|
+
state = self._state_for(account)
|
|
77
|
+
handle_rate_limit(state, error)
|
|
78
|
+
await self._sync_state(account, state)
|
|
79
|
+
|
|
80
|
+
async def mark_quota_exceeded(self, account: Account, error: UpstreamError) -> None:
|
|
81
|
+
state = self._state_for(account)
|
|
82
|
+
handle_quota_exceeded(state, error)
|
|
83
|
+
await self._sync_state(account, state)
|
|
84
|
+
|
|
85
|
+
async def mark_permanent_failure(self, account: Account, error_code: str) -> None:
|
|
86
|
+
state = self._state_for(account)
|
|
87
|
+
handle_permanent_failure(state, error_code)
|
|
88
|
+
await self._sync_state(account, state)
|
|
89
|
+
|
|
90
|
+
async def record_error(self, account: Account) -> None:
|
|
91
|
+
state = self._state_for(account)
|
|
92
|
+
state.error_count += 1
|
|
93
|
+
state.last_error_at = time.time()
|
|
94
|
+
await self._sync_state(account, state)
|
|
95
|
+
|
|
96
|
+
def _state_for(self, account: Account) -> AccountState:
|
|
97
|
+
runtime = self._runtime.setdefault(account.id, RuntimeState())
|
|
98
|
+
return AccountState(
|
|
99
|
+
account_id=account.id,
|
|
100
|
+
status=account.status,
|
|
101
|
+
used_percent=None,
|
|
102
|
+
reset_at=runtime.reset_at,
|
|
103
|
+
last_error_at=runtime.last_error_at,
|
|
104
|
+
last_selected_at=runtime.last_selected_at,
|
|
105
|
+
error_count=runtime.error_count,
|
|
106
|
+
deactivation_reason=account.deactivation_reason,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
async def _sync_state(self, account: Account, state: AccountState) -> None:
|
|
110
|
+
runtime = self._runtime.setdefault(account.id, RuntimeState())
|
|
111
|
+
runtime.reset_at = state.reset_at
|
|
112
|
+
runtime.last_error_at = state.last_error_at
|
|
113
|
+
runtime.error_count = state.error_count
|
|
114
|
+
|
|
115
|
+
if account.status != state.status or account.deactivation_reason != state.deactivation_reason:
|
|
116
|
+
await self._accounts_repo.update_status(
|
|
117
|
+
account.id,
|
|
118
|
+
state.status,
|
|
119
|
+
state.deactivation_reason,
|
|
120
|
+
)
|
|
121
|
+
account.status = state.status
|
|
122
|
+
account.deactivation_reason = state.deactivation_reason
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _build_states(
|
|
126
|
+
*,
|
|
127
|
+
accounts: Iterable[Account],
|
|
128
|
+
latest_primary: dict[str, UsageHistory],
|
|
129
|
+
latest_secondary: dict[str, UsageHistory],
|
|
130
|
+
runtime: dict[str, RuntimeState],
|
|
131
|
+
) -> tuple[list[AccountState], dict[str, Account]]:
|
|
132
|
+
states: list[AccountState] = []
|
|
133
|
+
account_map: dict[str, Account] = {}
|
|
134
|
+
|
|
135
|
+
for account in accounts:
|
|
136
|
+
state = _state_from_account(
|
|
137
|
+
account=account,
|
|
138
|
+
primary_entry=latest_primary.get(account.id),
|
|
139
|
+
secondary_entry=latest_secondary.get(account.id),
|
|
140
|
+
runtime=runtime.setdefault(account.id, RuntimeState()),
|
|
141
|
+
)
|
|
142
|
+
states.append(state)
|
|
143
|
+
account_map[account.id] = account
|
|
144
|
+
return states, account_map
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _state_from_account(
|
|
148
|
+
*,
|
|
149
|
+
account: Account,
|
|
150
|
+
primary_entry: UsageHistory | None,
|
|
151
|
+
secondary_entry: UsageHistory | None,
|
|
152
|
+
runtime: RuntimeState,
|
|
153
|
+
) -> AccountState:
|
|
154
|
+
primary_used = primary_entry.used_percent if primary_entry else None
|
|
155
|
+
secondary_used = secondary_entry.used_percent if secondary_entry else None
|
|
156
|
+
secondary_reset = secondary_entry.reset_at if secondary_entry else None
|
|
157
|
+
|
|
158
|
+
status, used_percent, reset_at = _apply_secondary_quota(
|
|
159
|
+
status=account.status,
|
|
160
|
+
primary_used=primary_used,
|
|
161
|
+
runtime_reset=runtime.reset_at,
|
|
162
|
+
secondary_used=secondary_used,
|
|
163
|
+
secondary_reset=secondary_reset,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
return AccountState(
|
|
167
|
+
account_id=account.id,
|
|
168
|
+
status=status,
|
|
169
|
+
used_percent=used_percent,
|
|
170
|
+
reset_at=reset_at,
|
|
171
|
+
last_error_at=runtime.last_error_at,
|
|
172
|
+
last_selected_at=runtime.last_selected_at,
|
|
173
|
+
error_count=runtime.error_count,
|
|
174
|
+
deactivation_reason=account.deactivation_reason,
|
|
175
|
+
)
|
|
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
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict
|
|
4
|
+
|
|
5
|
+
from app.core.types import JsonValue
|
|
6
|
+
from app.modules.proxy.types import (
|
|
7
|
+
CreditStatusDetailsData,
|
|
8
|
+
RateLimitStatusDetailsData,
|
|
9
|
+
RateLimitStatusPayloadData,
|
|
10
|
+
RateLimitWindowSnapshotData,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RateLimitWindowSnapshot(BaseModel):
|
|
15
|
+
model_config = ConfigDict(extra="ignore")
|
|
16
|
+
|
|
17
|
+
used_percent: int
|
|
18
|
+
limit_window_seconds: int
|
|
19
|
+
reset_after_seconds: int
|
|
20
|
+
reset_at: int
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def from_data(cls, data: RateLimitWindowSnapshotData) -> "RateLimitWindowSnapshot":
|
|
24
|
+
return cls(
|
|
25
|
+
used_percent=data.used_percent,
|
|
26
|
+
limit_window_seconds=data.limit_window_seconds,
|
|
27
|
+
reset_after_seconds=data.reset_after_seconds,
|
|
28
|
+
reset_at=data.reset_at,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class RateLimitStatusDetails(BaseModel):
|
|
33
|
+
model_config = ConfigDict(extra="ignore")
|
|
34
|
+
|
|
35
|
+
allowed: bool
|
|
36
|
+
limit_reached: bool
|
|
37
|
+
primary_window: RateLimitWindowSnapshot | None = None
|
|
38
|
+
secondary_window: RateLimitWindowSnapshot | None = None
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def from_data(cls, data: RateLimitStatusDetailsData) -> "RateLimitStatusDetails":
|
|
42
|
+
return cls(
|
|
43
|
+
allowed=data.allowed,
|
|
44
|
+
limit_reached=data.limit_reached,
|
|
45
|
+
primary_window=RateLimitWindowSnapshot.from_data(data.primary_window) if data.primary_window else None,
|
|
46
|
+
secondary_window=RateLimitWindowSnapshot.from_data(data.secondary_window)
|
|
47
|
+
if data.secondary_window
|
|
48
|
+
else None,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class CreditStatusDetails(BaseModel):
|
|
53
|
+
model_config = ConfigDict(extra="ignore")
|
|
54
|
+
|
|
55
|
+
has_credits: bool
|
|
56
|
+
unlimited: bool
|
|
57
|
+
balance: str | None = None
|
|
58
|
+
approx_local_messages: list[JsonValue] | None = None
|
|
59
|
+
approx_cloud_messages: list[JsonValue] | None = None
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def from_data(cls, data: CreditStatusDetailsData) -> "CreditStatusDetails":
|
|
63
|
+
return cls(
|
|
64
|
+
has_credits=data.has_credits,
|
|
65
|
+
unlimited=data.unlimited,
|
|
66
|
+
balance=data.balance,
|
|
67
|
+
approx_local_messages=data.approx_local_messages,
|
|
68
|
+
approx_cloud_messages=data.approx_cloud_messages,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class RateLimitStatusPayload(BaseModel):
|
|
73
|
+
model_config = ConfigDict(extra="ignore")
|
|
74
|
+
|
|
75
|
+
plan_type: str
|
|
76
|
+
rate_limit: RateLimitStatusDetails | None = None
|
|
77
|
+
credits: CreditStatusDetails | None = None
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def from_data(cls, data: RateLimitStatusPayloadData) -> "RateLimitStatusPayload":
|
|
81
|
+
return cls(
|
|
82
|
+
plan_type=data.plan_type,
|
|
83
|
+
rate_limit=RateLimitStatusDetails.from_data(data.rate_limit) if data.rate_limit else None,
|
|
84
|
+
credits=CreditStatusDetails.from_data(data.credits) if data.credits else None,
|
|
85
|
+
)
|