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.
Files changed (80) hide show
  1. app/__init__.py +5 -0
  2. app/cli.py +24 -0
  3. app/core/__init__.py +0 -0
  4. app/core/auth/__init__.py +96 -0
  5. app/core/auth/models.py +49 -0
  6. app/core/auth/refresh.py +144 -0
  7. app/core/balancer/__init__.py +19 -0
  8. app/core/balancer/logic.py +140 -0
  9. app/core/balancer/types.py +9 -0
  10. app/core/clients/__init__.py +0 -0
  11. app/core/clients/http.py +39 -0
  12. app/core/clients/oauth.py +340 -0
  13. app/core/clients/proxy.py +265 -0
  14. app/core/clients/usage.py +143 -0
  15. app/core/config/__init__.py +0 -0
  16. app/core/config/settings.py +69 -0
  17. app/core/crypto.py +37 -0
  18. app/core/errors.py +73 -0
  19. app/core/openai/__init__.py +0 -0
  20. app/core/openai/models.py +122 -0
  21. app/core/openai/parsing.py +55 -0
  22. app/core/openai/requests.py +59 -0
  23. app/core/types.py +4 -0
  24. app/core/usage/__init__.py +185 -0
  25. app/core/usage/logs.py +57 -0
  26. app/core/usage/models.py +35 -0
  27. app/core/usage/pricing.py +172 -0
  28. app/core/usage/types.py +95 -0
  29. app/core/utils/__init__.py +0 -0
  30. app/core/utils/request_id.py +30 -0
  31. app/core/utils/retry.py +16 -0
  32. app/core/utils/sse.py +13 -0
  33. app/core/utils/time.py +19 -0
  34. app/db/__init__.py +0 -0
  35. app/db/models.py +82 -0
  36. app/db/session.py +44 -0
  37. app/dependencies.py +123 -0
  38. app/main.py +124 -0
  39. app/modules/__init__.py +0 -0
  40. app/modules/accounts/__init__.py +0 -0
  41. app/modules/accounts/api.py +81 -0
  42. app/modules/accounts/repository.py +80 -0
  43. app/modules/accounts/schemas.py +66 -0
  44. app/modules/accounts/service.py +211 -0
  45. app/modules/health/__init__.py +0 -0
  46. app/modules/health/api.py +10 -0
  47. app/modules/oauth/__init__.py +0 -0
  48. app/modules/oauth/api.py +57 -0
  49. app/modules/oauth/schemas.py +32 -0
  50. app/modules/oauth/service.py +356 -0
  51. app/modules/oauth/templates/oauth_success.html +122 -0
  52. app/modules/proxy/__init__.py +0 -0
  53. app/modules/proxy/api.py +76 -0
  54. app/modules/proxy/auth_manager.py +51 -0
  55. app/modules/proxy/load_balancer.py +208 -0
  56. app/modules/proxy/schemas.py +85 -0
  57. app/modules/proxy/service.py +707 -0
  58. app/modules/proxy/types.py +37 -0
  59. app/modules/proxy/usage_updater.py +147 -0
  60. app/modules/request_logs/__init__.py +0 -0
  61. app/modules/request_logs/api.py +31 -0
  62. app/modules/request_logs/repository.py +86 -0
  63. app/modules/request_logs/schemas.py +25 -0
  64. app/modules/request_logs/service.py +77 -0
  65. app/modules/shared/__init__.py +0 -0
  66. app/modules/shared/schemas.py +8 -0
  67. app/modules/usage/__init__.py +0 -0
  68. app/modules/usage/api.py +31 -0
  69. app/modules/usage/repository.py +113 -0
  70. app/modules/usage/schemas.py +62 -0
  71. app/modules/usage/service.py +246 -0
  72. app/static/7.css +1336 -0
  73. app/static/index.css +543 -0
  74. app/static/index.html +457 -0
  75. app/static/index.js +1898 -0
  76. codex_lb-0.1.2.dist-info/METADATA +108 -0
  77. codex_lb-0.1.2.dist-info/RECORD +80 -0
  78. codex_lb-0.1.2.dist-info/WHEEL +4 -0
  79. codex_lb-0.1.2.dist-info/entry_points.txt +2 -0
  80. codex_lb-0.1.2.dist-info/licenses/LICENSE +21 -0
app/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ __version__ = "0.1.1"
2
+
3
+ from app.main import app as app
4
+
5
+ __all__ = ["app", "__version__"]
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]}"
@@ -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")
@@ -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
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TypedDict
4
+
5
+
6
+ class UpstreamError(TypedDict, total=False):
7
+ message: str
8
+ resets_at: int | float
9
+ resets_in_seconds: int | float
File without changes
@@ -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