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
app/__init__.py
ADDED
app/cli.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
import uvicorn
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _parse_args() -> argparse.Namespace:
|
|
10
|
+
parser = argparse.ArgumentParser(description="Run the codex-lb API server.")
|
|
11
|
+
parser.add_argument("--host", default=os.getenv("HOST", "127.0.0.1"))
|
|
12
|
+
parser.add_argument("--port", type=int, default=int(os.getenv("PORT", "2455")))
|
|
13
|
+
|
|
14
|
+
return parser.parse_args()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main() -> None:
|
|
18
|
+
args = _parse_args()
|
|
19
|
+
|
|
20
|
+
uvicorn.run("app.main:app", host=args.host, port=args.port)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
if __name__ == "__main__":
|
|
24
|
+
main()
|
app/core/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from uuid import uuid4
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
11
|
+
|
|
12
|
+
DEFAULT_EMAIL = "unknown@example.com"
|
|
13
|
+
DEFAULT_PLAN = "unknown"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AuthTokens(BaseModel):
|
|
17
|
+
model_config = ConfigDict(populate_by_name=True, extra="ignore")
|
|
18
|
+
|
|
19
|
+
id_token: str = Field(alias="idToken")
|
|
20
|
+
access_token: str = Field(alias="accessToken")
|
|
21
|
+
refresh_token: str = Field(alias="refreshToken")
|
|
22
|
+
account_id: str | None = Field(default=None, alias="accountId")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AuthFile(BaseModel):
|
|
26
|
+
model_config = ConfigDict(populate_by_name=True, extra="ignore")
|
|
27
|
+
|
|
28
|
+
openai_api_key: str | None = Field(default=None, alias="OPENAI_API_KEY")
|
|
29
|
+
tokens: AuthTokens
|
|
30
|
+
last_refresh_at: datetime | None = Field(default=None, alias="lastRefreshAt")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class OpenAIAuthClaims(BaseModel):
|
|
34
|
+
model_config = ConfigDict(extra="ignore")
|
|
35
|
+
|
|
36
|
+
chatgpt_account_id: str | None = None
|
|
37
|
+
chatgpt_plan_type: str | None = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class IdTokenClaims(BaseModel):
|
|
41
|
+
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
|
42
|
+
|
|
43
|
+
email: str | None = None
|
|
44
|
+
chatgpt_account_id: str | None = None
|
|
45
|
+
chatgpt_plan_type: str | None = None
|
|
46
|
+
exp: int | float | str | None = None
|
|
47
|
+
auth: OpenAIAuthClaims | None = Field(
|
|
48
|
+
default=None,
|
|
49
|
+
alias="https://api.openai.com/auth",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class AccountClaims:
|
|
55
|
+
account_id: str | None
|
|
56
|
+
email: str | None
|
|
57
|
+
plan_type: str | None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def parse_auth_json(raw: bytes) -> AuthFile:
|
|
61
|
+
data = json.loads(raw)
|
|
62
|
+
model = AuthFile.model_validate(data)
|
|
63
|
+
return model
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def extract_id_token_claims(id_token: str) -> IdTokenClaims:
|
|
67
|
+
try:
|
|
68
|
+
parts = id_token.split(".")
|
|
69
|
+
if len(parts) < 2:
|
|
70
|
+
return IdTokenClaims()
|
|
71
|
+
payload = parts[1]
|
|
72
|
+
padding = "=" * (-len(payload) % 4)
|
|
73
|
+
decoded = base64.urlsafe_b64decode(payload + padding)
|
|
74
|
+
data = json.loads(decoded)
|
|
75
|
+
if not isinstance(data, dict):
|
|
76
|
+
return IdTokenClaims()
|
|
77
|
+
return IdTokenClaims.model_validate(data)
|
|
78
|
+
except Exception:
|
|
79
|
+
return IdTokenClaims()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def claims_from_auth(auth: AuthFile) -> AccountClaims:
|
|
83
|
+
claims = extract_id_token_claims(auth.tokens.id_token)
|
|
84
|
+
auth_claims = claims.auth or OpenAIAuthClaims()
|
|
85
|
+
return AccountClaims(
|
|
86
|
+
account_id=auth.tokens.account_id or auth_claims.chatgpt_account_id or claims.chatgpt_account_id,
|
|
87
|
+
email=claims.email,
|
|
88
|
+
plan_type=auth_claims.chatgpt_plan_type or claims.chatgpt_plan_type,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def fallback_account_id(email: str | None) -> str:
|
|
93
|
+
if email and email != DEFAULT_EMAIL:
|
|
94
|
+
digest = hashlib.sha256(email.encode()).hexdigest()[:12]
|
|
95
|
+
return f"email_{digest}"
|
|
96
|
+
return f"local_{uuid4().hex[:12]}"
|
app/core/auth/models.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, StrictStr, field_validator
|
|
4
|
+
|
|
5
|
+
from app.core.types import JsonObject
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class OAuthTokenPayload(BaseModel):
|
|
9
|
+
model_config = ConfigDict(extra="ignore")
|
|
10
|
+
|
|
11
|
+
access_token: StrictStr | None = None
|
|
12
|
+
refresh_token: StrictStr | None = None
|
|
13
|
+
id_token: StrictStr | None = None
|
|
14
|
+
authorization_code: StrictStr | None = None
|
|
15
|
+
code_verifier: StrictStr | None = None
|
|
16
|
+
error: JsonObject | StrictStr | None = None
|
|
17
|
+
error_description: StrictStr | None = None
|
|
18
|
+
message: StrictStr | None = None
|
|
19
|
+
error_code: StrictStr | None = None
|
|
20
|
+
code: StrictStr | None = None
|
|
21
|
+
status: StrictStr | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DeviceCodePayload(BaseModel):
|
|
25
|
+
model_config = ConfigDict(extra="ignore")
|
|
26
|
+
|
|
27
|
+
device_auth_id: StrictStr | None = None
|
|
28
|
+
user_code: StrictStr | None = Field(
|
|
29
|
+
default=None,
|
|
30
|
+
validation_alias=AliasChoices("user_code", "usercode"),
|
|
31
|
+
)
|
|
32
|
+
interval: int | None = None
|
|
33
|
+
expires_in: int | None = None
|
|
34
|
+
expires_at: StrictStr | None = None
|
|
35
|
+
|
|
36
|
+
@field_validator("interval", mode="before")
|
|
37
|
+
@classmethod
|
|
38
|
+
def _parse_interval(cls, value: object) -> int | None:
|
|
39
|
+
if value is None:
|
|
40
|
+
return None
|
|
41
|
+
if isinstance(value, int):
|
|
42
|
+
return value
|
|
43
|
+
if isinstance(value, str):
|
|
44
|
+
stripped = value.strip()
|
|
45
|
+
if not stripped:
|
|
46
|
+
return None
|
|
47
|
+
if stripped.isdigit():
|
|
48
|
+
return int(stripped)
|
|
49
|
+
raise ValueError("Invalid interval")
|
app/core/auth/refresh.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import datetime, timedelta
|
|
6
|
+
|
|
7
|
+
import aiohttp
|
|
8
|
+
from pydantic import ValidationError
|
|
9
|
+
|
|
10
|
+
from app.core.auth import OpenAIAuthClaims, extract_id_token_claims
|
|
11
|
+
from app.core.auth.models import OAuthTokenPayload
|
|
12
|
+
from app.core.balancer import PERMANENT_FAILURE_CODES
|
|
13
|
+
from app.core.clients.http import get_http_client
|
|
14
|
+
from app.core.config.settings import get_settings
|
|
15
|
+
from app.core.types import JsonObject
|
|
16
|
+
from app.core.utils.request_id import get_request_id
|
|
17
|
+
from app.core.utils.time import to_utc_naive, utcnow
|
|
18
|
+
|
|
19
|
+
TOKEN_REFRESH_INTERVAL_DAYS = 8
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class TokenRefreshResult:
|
|
26
|
+
access_token: str
|
|
27
|
+
refresh_token: str
|
|
28
|
+
id_token: str
|
|
29
|
+
account_id: str | None
|
|
30
|
+
plan_type: str | None
|
|
31
|
+
email: str | None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class RefreshError(Exception):
|
|
35
|
+
def __init__(self, code: str, message: str, is_permanent: bool) -> None:
|
|
36
|
+
super().__init__(message)
|
|
37
|
+
self.code = code
|
|
38
|
+
self.message = message
|
|
39
|
+
self.is_permanent = is_permanent
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def should_refresh(last_refresh: datetime, now: datetime | None = None) -> bool:
|
|
43
|
+
current = to_utc_naive(now) if now is not None else utcnow()
|
|
44
|
+
last = to_utc_naive(last_refresh)
|
|
45
|
+
interval_days = get_settings().token_refresh_interval_days or TOKEN_REFRESH_INTERVAL_DAYS
|
|
46
|
+
return current - last > timedelta(days=interval_days)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def classify_refresh_error(code: str | None) -> bool:
|
|
50
|
+
if not code:
|
|
51
|
+
return False
|
|
52
|
+
return code in PERMANENT_FAILURE_CODES
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def refresh_access_token(
|
|
56
|
+
refresh_token: str,
|
|
57
|
+
*,
|
|
58
|
+
session: aiohttp.ClientSession | None = None,
|
|
59
|
+
) -> TokenRefreshResult:
|
|
60
|
+
settings = get_settings()
|
|
61
|
+
url = f"{settings.auth_base_url.rstrip('/')}/oauth/token"
|
|
62
|
+
payload = {
|
|
63
|
+
"grant_type": "refresh_token",
|
|
64
|
+
"client_id": settings.oauth_client_id,
|
|
65
|
+
"refresh_token": refresh_token,
|
|
66
|
+
"scope": settings.oauth_scope,
|
|
67
|
+
}
|
|
68
|
+
timeout = aiohttp.ClientTimeout(total=settings.token_refresh_timeout_seconds)
|
|
69
|
+
|
|
70
|
+
client_session = session or get_http_client().session
|
|
71
|
+
headers: dict[str, str] = {}
|
|
72
|
+
request_id = get_request_id()
|
|
73
|
+
if request_id:
|
|
74
|
+
headers["x-request-id"] = request_id
|
|
75
|
+
async with client_session.post(url, json=payload, headers=headers, timeout=timeout) as resp:
|
|
76
|
+
data = await _safe_json(resp)
|
|
77
|
+
try:
|
|
78
|
+
payload_data = OAuthTokenPayload.model_validate(data)
|
|
79
|
+
except ValidationError as exc:
|
|
80
|
+
logger.warning(
|
|
81
|
+
"Token refresh response invalid request_id=%s",
|
|
82
|
+
get_request_id(),
|
|
83
|
+
)
|
|
84
|
+
raise RefreshError("invalid_response", "Refresh response invalid", False) from exc
|
|
85
|
+
if resp.status >= 400:
|
|
86
|
+
logger.warning(
|
|
87
|
+
"Token refresh failed request_id=%s status=%s",
|
|
88
|
+
get_request_id(),
|
|
89
|
+
resp.status,
|
|
90
|
+
)
|
|
91
|
+
raise _refresh_error_from_payload(payload_data, resp.status)
|
|
92
|
+
|
|
93
|
+
if not payload_data.access_token or not payload_data.refresh_token or not payload_data.id_token:
|
|
94
|
+
raise RefreshError("invalid_response", "Refresh response missing tokens", False)
|
|
95
|
+
|
|
96
|
+
claims = extract_id_token_claims(payload_data.id_token)
|
|
97
|
+
auth_claims = claims.auth or OpenAIAuthClaims()
|
|
98
|
+
account_id = auth_claims.chatgpt_account_id or claims.chatgpt_account_id
|
|
99
|
+
plan_type = auth_claims.chatgpt_plan_type or claims.chatgpt_plan_type
|
|
100
|
+
email = claims.email
|
|
101
|
+
|
|
102
|
+
return TokenRefreshResult(
|
|
103
|
+
access_token=payload_data.access_token,
|
|
104
|
+
refresh_token=payload_data.refresh_token,
|
|
105
|
+
id_token=payload_data.id_token,
|
|
106
|
+
account_id=account_id,
|
|
107
|
+
plan_type=plan_type,
|
|
108
|
+
email=email,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
async def _safe_json(resp: aiohttp.ClientResponse) -> JsonObject:
|
|
113
|
+
try:
|
|
114
|
+
data = await resp.json(content_type=None)
|
|
115
|
+
except Exception:
|
|
116
|
+
text = await resp.text()
|
|
117
|
+
return {"error": {"message": text.strip()}}
|
|
118
|
+
return data if isinstance(data, dict) else {"error": {"message": str(data)}}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _refresh_error_from_payload(payload: OAuthTokenPayload, status_code: int) -> RefreshError:
|
|
122
|
+
code = _extract_error_code(payload) or f"http_{status_code}"
|
|
123
|
+
message = _extract_error_message(payload) or f"Token refresh failed ({status_code})"
|
|
124
|
+
return RefreshError(code, message, classify_refresh_error(code))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _extract_error_code(payload: OAuthTokenPayload) -> str | None:
|
|
128
|
+
error = payload.error
|
|
129
|
+
if isinstance(error, dict):
|
|
130
|
+
code = error.get("code") or error.get("error")
|
|
131
|
+
return code if isinstance(code, str) else None
|
|
132
|
+
if isinstance(error, str):
|
|
133
|
+
return error
|
|
134
|
+
return payload.error_code or payload.code
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _extract_error_message(payload: OAuthTokenPayload) -> str | None:
|
|
138
|
+
error = payload.error
|
|
139
|
+
if isinstance(error, dict):
|
|
140
|
+
message = error.get("message") or error.get("error_description")
|
|
141
|
+
return message if isinstance(message, str) else None
|
|
142
|
+
if isinstance(error, str):
|
|
143
|
+
return payload.error_description or error
|
|
144
|
+
return payload.message
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from app.core.balancer.logic import (
|
|
2
|
+
PERMANENT_FAILURE_CODES,
|
|
3
|
+
AccountState,
|
|
4
|
+
SelectionResult,
|
|
5
|
+
handle_permanent_failure,
|
|
6
|
+
handle_quota_exceeded,
|
|
7
|
+
handle_rate_limit,
|
|
8
|
+
select_account,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"PERMANENT_FAILURE_CODES",
|
|
13
|
+
"AccountState",
|
|
14
|
+
"SelectionResult",
|
|
15
|
+
"handle_permanent_failure",
|
|
16
|
+
"handle_quota_exceeded",
|
|
17
|
+
"handle_rate_limit",
|
|
18
|
+
"select_account",
|
|
19
|
+
]
|
|
@@ -0,0 +1,140 @@
|
|
|
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.types import UpstreamError
|
|
8
|
+
from app.core.utils.retry import parse_retry_after
|
|
9
|
+
from app.db.models import AccountStatus
|
|
10
|
+
|
|
11
|
+
PERMANENT_FAILURE_CODES = {
|
|
12
|
+
"refresh_token_expired": "Refresh token expired - re-login required",
|
|
13
|
+
"refresh_token_reused": "Refresh token was reused - re-login required",
|
|
14
|
+
"refresh_token_invalidated": "Refresh token was revoked - re-login required",
|
|
15
|
+
"account_suspended": "Account has been suspended",
|
|
16
|
+
"account_deleted": "Account has been deleted",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class AccountState:
|
|
22
|
+
account_id: str
|
|
23
|
+
status: AccountStatus
|
|
24
|
+
used_percent: float | None = None
|
|
25
|
+
reset_at: int | None = None
|
|
26
|
+
last_error_at: float | None = None
|
|
27
|
+
last_selected_at: float | None = None
|
|
28
|
+
error_count: int = 0
|
|
29
|
+
deactivation_reason: str | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class SelectionResult:
|
|
34
|
+
account: AccountState | None
|
|
35
|
+
error_message: str | None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def select_account(states: Iterable[AccountState], now: float | None = None) -> SelectionResult:
|
|
39
|
+
current = now or time.time()
|
|
40
|
+
available: list[AccountState] = []
|
|
41
|
+
all_states = list(states)
|
|
42
|
+
|
|
43
|
+
for state in all_states:
|
|
44
|
+
if state.status == AccountStatus.DEACTIVATED:
|
|
45
|
+
continue
|
|
46
|
+
if state.status == AccountStatus.PAUSED:
|
|
47
|
+
continue
|
|
48
|
+
if state.status == AccountStatus.RATE_LIMITED:
|
|
49
|
+
if state.reset_at and current >= state.reset_at:
|
|
50
|
+
state.status = AccountStatus.ACTIVE
|
|
51
|
+
state.error_count = 0
|
|
52
|
+
state.reset_at = None
|
|
53
|
+
else:
|
|
54
|
+
continue
|
|
55
|
+
if state.status == AccountStatus.QUOTA_EXCEEDED:
|
|
56
|
+
if state.reset_at and current >= state.reset_at:
|
|
57
|
+
state.status = AccountStatus.ACTIVE
|
|
58
|
+
state.used_percent = 0.0
|
|
59
|
+
state.reset_at = None
|
|
60
|
+
else:
|
|
61
|
+
continue
|
|
62
|
+
if state.error_count >= 3:
|
|
63
|
+
backoff = min(300, 30 * (2 ** (state.error_count - 3)))
|
|
64
|
+
if state.last_error_at and current - state.last_error_at < backoff:
|
|
65
|
+
continue
|
|
66
|
+
available.append(state)
|
|
67
|
+
|
|
68
|
+
if not available:
|
|
69
|
+
deactivated = [s for s in all_states if s.status == AccountStatus.DEACTIVATED]
|
|
70
|
+
paused = [s for s in all_states if s.status == AccountStatus.PAUSED]
|
|
71
|
+
rate_limited = [s for s in all_states if s.status == AccountStatus.RATE_LIMITED]
|
|
72
|
+
quota_exceeded = [s for s in all_states if s.status == AccountStatus.QUOTA_EXCEEDED]
|
|
73
|
+
|
|
74
|
+
if paused and deactivated and not rate_limited and not quota_exceeded:
|
|
75
|
+
return SelectionResult(None, "All accounts are paused or require re-authentication")
|
|
76
|
+
if paused and not rate_limited and not quota_exceeded:
|
|
77
|
+
return SelectionResult(None, "All accounts are paused")
|
|
78
|
+
if deactivated and not rate_limited and not quota_exceeded:
|
|
79
|
+
return SelectionResult(None, "All accounts require re-authentication")
|
|
80
|
+
if quota_exceeded:
|
|
81
|
+
reset_candidates = [s.reset_at for s in quota_exceeded if s.reset_at]
|
|
82
|
+
if reset_candidates:
|
|
83
|
+
wait_seconds = max(0, min(reset_candidates) - int(current))
|
|
84
|
+
return SelectionResult(None, f"Rate limit exceeded. Try again in {wait_seconds:.0f}s")
|
|
85
|
+
return SelectionResult(None, "No available accounts")
|
|
86
|
+
|
|
87
|
+
def _sort_key(state: AccountState) -> tuple[float, float, str]:
|
|
88
|
+
used = state.used_percent if state.used_percent is not None else 0.0
|
|
89
|
+
last_selected = state.last_selected_at or 0.0
|
|
90
|
+
return used, last_selected, state.account_id
|
|
91
|
+
|
|
92
|
+
selected = min(available, key=_sort_key)
|
|
93
|
+
return SelectionResult(selected, None)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def handle_rate_limit(state: AccountState, error: UpstreamError) -> None:
|
|
97
|
+
state.status = AccountStatus.RATE_LIMITED
|
|
98
|
+
state.error_count += 1
|
|
99
|
+
state.last_error_at = time.time()
|
|
100
|
+
|
|
101
|
+
reset_at = _extract_reset_at(error)
|
|
102
|
+
if reset_at is not None:
|
|
103
|
+
state.reset_at = reset_at
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
message = error.get("message")
|
|
107
|
+
delay = parse_retry_after(message) if message else None
|
|
108
|
+
if delay:
|
|
109
|
+
state.reset_at = int(time.time() + delay)
|
|
110
|
+
else:
|
|
111
|
+
state.reset_at = int(time.time() + 300)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def handle_quota_exceeded(state: AccountState, error: UpstreamError) -> None:
|
|
115
|
+
state.status = AccountStatus.QUOTA_EXCEEDED
|
|
116
|
+
state.used_percent = 100.0
|
|
117
|
+
|
|
118
|
+
reset_at = _extract_reset_at(error)
|
|
119
|
+
if reset_at is not None:
|
|
120
|
+
state.reset_at = reset_at
|
|
121
|
+
else:
|
|
122
|
+
state.reset_at = int(time.time() + 3600)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def handle_permanent_failure(state: AccountState, error_code: str) -> None:
|
|
126
|
+
state.status = AccountStatus.DEACTIVATED
|
|
127
|
+
state.deactivation_reason = PERMANENT_FAILURE_CODES.get(
|
|
128
|
+
error_code,
|
|
129
|
+
f"Authentication failed: {error_code}",
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _extract_reset_at(error: UpstreamError) -> int | None:
|
|
134
|
+
reset_at = error.get("resets_at")
|
|
135
|
+
if reset_at is not None:
|
|
136
|
+
return int(reset_at)
|
|
137
|
+
reset_in = error.get("resets_in_seconds")
|
|
138
|
+
if reset_in is not None:
|
|
139
|
+
return int(time.time() + float(reset_in))
|
|
140
|
+
return None
|
|
File without changes
|
app/core/clients/http.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
import aiohttp
|
|
6
|
+
from aiohttp_retry import RetryClient
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(slots=True)
|
|
10
|
+
class HttpClient:
|
|
11
|
+
session: aiohttp.ClientSession
|
|
12
|
+
retry_client: RetryClient
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_http_client: HttpClient | None = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def init_http_client() -> HttpClient:
|
|
19
|
+
global _http_client
|
|
20
|
+
if _http_client is not None:
|
|
21
|
+
return _http_client
|
|
22
|
+
session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=None))
|
|
23
|
+
retry_client = RetryClient(client_session=session, raise_for_status=False)
|
|
24
|
+
_http_client = HttpClient(session=session, retry_client=retry_client)
|
|
25
|
+
return _http_client
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def close_http_client() -> None:
|
|
29
|
+
global _http_client
|
|
30
|
+
if _http_client is None:
|
|
31
|
+
return
|
|
32
|
+
await _http_client.retry_client.close()
|
|
33
|
+
_http_client = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_http_client() -> HttpClient:
|
|
37
|
+
if _http_client is None:
|
|
38
|
+
raise RuntimeError("HTTP client not initialized")
|
|
39
|
+
return _http_client
|