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
@@ -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=&quot;http://www.w3.org/2000/svg&quot; width=&quot;32&quot; height=&quot;32&quot; fill=&quot;none&quot; viewBox=&quot;0 0 32 32&quot;%3E%3Cpath stroke=&quot;%23000&quot; stroke-linecap=&quot;round&quot; stroke-width=&quot;2.484&quot; d=&quot;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&quot;/%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
@@ -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
+ )