codex-lb 0.1.4__py3-none-any.whl → 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from datetime import datetime, timedelta, timezone
4
+ from typing import cast
4
5
 
5
6
  from app.core import usage as usage_core
6
7
  from app.core.auth import (
@@ -12,7 +13,8 @@ from app.core.auth import (
12
13
  parse_auth_json,
13
14
  )
14
15
  from app.core.crypto import TokenEncryptor
15
- from app.core.usage.logs import cost_from_log
16
+ from app.core.plan_types import coerce_account_plan_type
17
+ from app.core.usage.logs import RequestLogLike, cost_from_log
16
18
  from app.core.utils.time import from_epoch_seconds, to_utc_naive, utcnow
17
19
  from app.db.models import Account, AccountStatus, UsageHistory
18
20
  from app.modules.accounts.repository import AccountsRepository
@@ -23,9 +25,9 @@ from app.modules.accounts.schemas import (
23
25
  AccountTokenStatus,
24
26
  AccountUsage,
25
27
  )
26
- from app.modules.proxy.usage_updater import UsageUpdater
27
28
  from app.modules.request_logs.repository import RequestLogsRepository
28
29
  from app.modules.usage.repository import UsageRepository
30
+ from app.modules.usage.updater import UsageUpdater
29
31
 
30
32
 
31
33
  class AccountsService:
@@ -64,7 +66,7 @@ class AccountsService:
64
66
  claims = claims_from_auth(auth)
65
67
 
66
68
  email = claims.email or DEFAULT_EMAIL
67
- plan_type = claims.plan_type or DEFAULT_PLAN
69
+ plan_type = coerce_account_plan_type(claims.plan_type, DEFAULT_PLAN)
68
70
  account_id = claims.account_id or fallback_account_id(email)
69
71
  last_refresh = to_utc_naive(auth.last_refresh_at) if auth.last_refresh_at else utcnow()
70
72
 
@@ -107,6 +109,7 @@ class AccountsService:
107
109
  secondary_usage: UsageHistory | None,
108
110
  cost_usd_24h: float | None,
109
111
  ) -> AccountSummary:
112
+ plan_type = coerce_account_plan_type(account.plan_type, DEFAULT_PLAN)
110
113
  auth_status = self._build_auth_status(account)
111
114
  primary_used_percent = _normalize_used_percent(primary_usage) or 0.0
112
115
  secondary_used_percent = _normalize_used_percent(secondary_usage) or 0.0
@@ -114,8 +117,8 @@ class AccountsService:
114
117
  secondary_remaining_percent = usage_core.remaining_percent_from_used(secondary_used_percent) or 0.0
115
118
  reset_at_primary = from_epoch_seconds(primary_usage.reset_at) if primary_usage is not None else None
116
119
  reset_at_secondary = from_epoch_seconds(secondary_usage.reset_at) if secondary_usage is not None else None
117
- capacity_primary = usage_core.capacity_for_plan(account.plan_type, "primary")
118
- capacity_secondary = usage_core.capacity_for_plan(account.plan_type, "secondary")
120
+ capacity_primary = usage_core.capacity_for_plan(plan_type, "primary")
121
+ capacity_secondary = usage_core.capacity_for_plan(plan_type, "secondary")
119
122
  remaining_credits_primary = usage_core.remaining_credits_from_percent(
120
123
  primary_used_percent,
121
124
  capacity_primary,
@@ -128,7 +131,7 @@ class AccountsService:
128
131
  account_id=account.id,
129
132
  email=account.email,
130
133
  display_name=account.email,
131
- plan_type=account.plan_type,
134
+ plan_type=plan_type,
132
135
  status=account.status.value,
133
136
  usage=AccountUsage(
134
137
  primary_remaining_percent=primary_remaining_percent,
@@ -186,7 +189,7 @@ class AccountsService:
186
189
  logs = await self._logs_repo.list_since(since)
187
190
  totals: dict[str, float] = {}
188
191
  for log in logs:
189
- cost = cost_from_log(log)
192
+ cost = cost_from_log(cast(RequestLogLike, log))
190
193
  if cost is None:
191
194
  continue
192
195
  totals[log.account_id] = totals.get(log.account_id, 0.0) + cost
app/modules/health/api.py CHANGED
@@ -2,9 +2,11 @@ from __future__ import annotations
2
2
 
3
3
  from fastapi import APIRouter
4
4
 
5
+ from app.modules.health.schemas import HealthResponse
6
+
5
7
  router = APIRouter(tags=["health"])
6
8
 
7
9
 
8
- @router.get("/health")
9
- async def health_check() -> dict:
10
- return {"status": "ok"}
10
+ @router.get("/health", response_model=HealthResponse)
11
+ async def health_check() -> HealthResponse:
12
+ return HealthResponse(status="ok")
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel, ConfigDict
4
+
5
+
6
+ class HealthResponse(BaseModel):
7
+ model_config = ConfigDict(extra="ignore")
8
+
9
+ status: str
@@ -28,6 +28,7 @@ from app.core.clients.oauth import (
28
28
  )
29
29
  from app.core.config.settings import get_settings
30
30
  from app.core.crypto import TokenEncryptor
31
+ from app.core.plan_types import coerce_account_plan_type
31
32
  from app.core.utils.time import utcnow
32
33
  from app.db.models import Account, AccountStatus
33
34
  from app.modules.accounts.repository import AccountsRepository
@@ -295,7 +296,10 @@ class OauthService:
295
296
  auth_claims = claims.auth or OpenAIAuthClaims()
296
297
  account_id = auth_claims.chatgpt_account_id or claims.chatgpt_account_id
297
298
  email = claims.email or DEFAULT_EMAIL
298
- plan_type = auth_claims.chatgpt_plan_type or claims.chatgpt_plan_type or DEFAULT_PLAN
299
+ plan_type = coerce_account_plan_type(
300
+ auth_claims.chatgpt_plan_type or claims.chatgpt_plan_type,
301
+ DEFAULT_PLAN,
302
+ )
299
303
  account_id = account_id or fallback_account_id(email)
300
304
 
301
305
  account = Account(
@@ -0,0 +1,285 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Iterable
4
+
5
+ from pydantic import ValidationError
6
+
7
+ from app.core import usage as usage_core
8
+ from app.core.balancer.types import UpstreamError
9
+ from app.core.errors import OpenAIErrorDetail, OpenAIErrorEnvelope
10
+ from app.core.openai.models import OpenAIError
11
+ from app.core.plan_types import normalize_rate_limit_plan_type
12
+ from app.core.usage.types import UsageWindowRow, UsageWindowSummary
13
+ from app.db.models import Account, AccountStatus, UsageHistory
14
+ from app.modules.proxy.types import (
15
+ CreditStatusDetailsData,
16
+ RateLimitStatusDetailsData,
17
+ RateLimitWindowSnapshotData,
18
+ )
19
+
20
+ PLAN_TYPE_PRIORITY = (
21
+ "enterprise",
22
+ "business",
23
+ "team",
24
+ "pro",
25
+ "plus",
26
+ "education",
27
+ "edu",
28
+ "free_workspace",
29
+ "free",
30
+ "go",
31
+ "guest",
32
+ "quorum",
33
+ "k12",
34
+ )
35
+
36
+
37
+ def _header_account_id(account_id: str | None) -> str | None:
38
+ if not account_id:
39
+ return None
40
+ if account_id.startswith(("email_", "local_")):
41
+ return None
42
+ return account_id
43
+
44
+
45
+ def _select_accounts_for_limits(accounts: Iterable[Account]) -> list[Account]:
46
+ return [account for account in accounts if account.status not in (AccountStatus.DEACTIVATED, AccountStatus.PAUSED)]
47
+
48
+
49
+ def _summarize_window(
50
+ rows: list[UsageWindowRow],
51
+ account_map: dict[str, Account],
52
+ window: str,
53
+ ) -> UsageWindowSummary | None:
54
+ if not rows:
55
+ return None
56
+ return usage_core.summarize_usage_window(rows, account_map, window)
57
+
58
+
59
+ def _window_snapshot(
60
+ summary: UsageWindowSummary | None,
61
+ rows: list[UsageWindowRow],
62
+ window: str,
63
+ now_epoch: int,
64
+ ) -> RateLimitWindowSnapshotData | None:
65
+ if summary is None:
66
+ return None
67
+
68
+ used_percent = _normalize_used_percent(summary.used_percent, rows)
69
+ if used_percent is None:
70
+ return None
71
+
72
+ reset_at = summary.reset_at
73
+ if reset_at is None:
74
+ return None
75
+
76
+ window_minutes = summary.window_minutes or usage_core.default_window_minutes(window)
77
+ if not window_minutes:
78
+ return None
79
+
80
+ limit_window_seconds = int(window_minutes * 60)
81
+ reset_after_seconds = max(0, int(reset_at) - now_epoch)
82
+
83
+ return RateLimitWindowSnapshotData(
84
+ used_percent=_percent_to_int(used_percent),
85
+ limit_window_seconds=limit_window_seconds,
86
+ reset_after_seconds=reset_after_seconds,
87
+ reset_at=int(reset_at),
88
+ )
89
+
90
+
91
+ def _normalize_used_percent(
92
+ value: float | None,
93
+ rows: Iterable[UsageWindowRow],
94
+ ) -> float | None:
95
+ if value is not None:
96
+ return value
97
+ values = [row.used_percent for row in rows if row.used_percent is not None]
98
+ if not values:
99
+ return None
100
+ return sum(values) / len(values)
101
+
102
+
103
+ def _percent_to_int(value: float) -> int:
104
+ bounded = max(0.0, min(100.0, value))
105
+ return int(bounded)
106
+
107
+
108
+ def _rate_limit_details(
109
+ primary: RateLimitWindowSnapshotData | None,
110
+ secondary: RateLimitWindowSnapshotData | None,
111
+ ) -> RateLimitStatusDetailsData | None:
112
+ if not primary and not secondary:
113
+ return None
114
+ used_percents = [window.used_percent for window in (primary, secondary) if window]
115
+ limit_reached = any(used >= 100 for used in used_percents)
116
+ return RateLimitStatusDetailsData(
117
+ allowed=not limit_reached,
118
+ limit_reached=limit_reached,
119
+ primary_window=primary,
120
+ secondary_window=secondary,
121
+ )
122
+
123
+
124
+ def _aggregate_credits(entries: Iterable[UsageHistory]) -> tuple[bool, bool, float] | None:
125
+ has_data = False
126
+ has_credits = False
127
+ unlimited = False
128
+ balance_total = 0.0
129
+
130
+ for entry in entries:
131
+ credits_has = entry.credits_has
132
+ credits_unlimited = entry.credits_unlimited
133
+ credits_balance = entry.credits_balance
134
+ if credits_has is None and credits_unlimited is None and credits_balance is None:
135
+ continue
136
+ has_data = True
137
+ if credits_has is True:
138
+ has_credits = True
139
+ if credits_unlimited is True:
140
+ unlimited = True
141
+ if credits_balance is not None and not credits_unlimited:
142
+ try:
143
+ balance_total += float(credits_balance)
144
+ except (TypeError, ValueError):
145
+ continue
146
+
147
+ if not has_data:
148
+ return None
149
+ if unlimited:
150
+ has_credits = True
151
+ return has_credits, unlimited, balance_total
152
+
153
+
154
+ def _credits_snapshot(entries: Iterable[UsageHistory]) -> CreditStatusDetailsData | None:
155
+ aggregate = _aggregate_credits(entries)
156
+ if aggregate is None:
157
+ return None
158
+ has_credits, unlimited, balance_total = aggregate
159
+ balance_value = str(round(balance_total, 2))
160
+ return CreditStatusDetailsData(
161
+ has_credits=has_credits,
162
+ unlimited=unlimited,
163
+ balance=balance_value,
164
+ approx_local_messages=None,
165
+ approx_cloud_messages=None,
166
+ )
167
+
168
+
169
+ def _plan_type_for_accounts(accounts: Iterable[Account]) -> str:
170
+ normalized = [_normalize_plan_type(account.plan_type) for account in accounts]
171
+ filtered = [plan for plan in normalized if plan is not None]
172
+ if not filtered:
173
+ return "guest"
174
+ unique = set(filtered)
175
+ if len(unique) == 1:
176
+ return filtered[0]
177
+ for plan in PLAN_TYPE_PRIORITY:
178
+ if plan in unique:
179
+ return plan
180
+ return "guest"
181
+
182
+
183
+ def _normalize_plan_type(value: str | None) -> str | None:
184
+ return normalize_rate_limit_plan_type(value)
185
+
186
+
187
+ def _rate_limit_headers(
188
+ window_label: str,
189
+ summary: UsageWindowSummary,
190
+ ) -> dict[str, str]:
191
+ used_percent = summary.used_percent
192
+ window_minutes = summary.window_minutes
193
+ if used_percent is None or window_minutes is None:
194
+ return {}
195
+ headers = {
196
+ f"x-codex-{window_label}-used-percent": str(float(used_percent)),
197
+ f"x-codex-{window_label}-window-minutes": str(int(window_minutes)),
198
+ }
199
+ reset_at = summary.reset_at
200
+ if reset_at is not None:
201
+ headers[f"x-codex-{window_label}-reset-at"] = str(int(reset_at))
202
+ return headers
203
+
204
+
205
+ def _credits_headers(entries: Iterable[UsageHistory]) -> dict[str, str]:
206
+ aggregate = _aggregate_credits(entries)
207
+ if aggregate is None:
208
+ return {}
209
+ has_credits, unlimited, balance_total = aggregate
210
+ balance_value = f"{balance_total:.2f}"
211
+ return {
212
+ "x-codex-credits-has-credits": "true" if has_credits else "false",
213
+ "x-codex-credits-unlimited": "true" if unlimited else "false",
214
+ "x-codex-credits-balance": balance_value,
215
+ }
216
+
217
+
218
+ def _normalize_error_code(code: str | None, error_type: str | None) -> str:
219
+ value = code or error_type
220
+ if not value:
221
+ return "upstream_error"
222
+ return value.lower()
223
+
224
+
225
+ def _parse_openai_error(payload: OpenAIErrorEnvelope) -> OpenAIError | None:
226
+ error = payload.get("error")
227
+ if not error:
228
+ return None
229
+ try:
230
+ return OpenAIError.model_validate(error)
231
+ except ValidationError:
232
+ if not isinstance(error, dict):
233
+ return None
234
+ return OpenAIError(
235
+ message=_coerce_str(error.get("message")),
236
+ type=_coerce_str(error.get("type")),
237
+ code=_coerce_str(error.get("code")),
238
+ param=_coerce_str(error.get("param")),
239
+ plan_type=_coerce_str(error.get("plan_type")),
240
+ resets_at=_coerce_number(error.get("resets_at")),
241
+ resets_in_seconds=_coerce_number(error.get("resets_in_seconds")),
242
+ )
243
+
244
+
245
+ def _coerce_str(value: object) -> str | None:
246
+ return value if isinstance(value, str) else None
247
+
248
+
249
+ def _coerce_number(value: object) -> int | float | None:
250
+ if isinstance(value, (int, float)):
251
+ return value
252
+ if isinstance(value, str):
253
+ try:
254
+ return float(value.strip())
255
+ except ValueError:
256
+ return None
257
+ return None
258
+
259
+
260
+ def _apply_error_metadata(target: OpenAIErrorDetail, error: OpenAIError | None) -> None:
261
+ if not error:
262
+ return
263
+ if error.plan_type is not None:
264
+ target["plan_type"] = error.plan_type
265
+ if error.resets_at is not None:
266
+ target["resets_at"] = error.resets_at
267
+ if error.resets_in_seconds is not None:
268
+ target["resets_in_seconds"] = error.resets_in_seconds
269
+
270
+
271
+ def _upstream_error_from_openai(error: OpenAIError | None) -> UpstreamError:
272
+ if not error:
273
+ return {}
274
+ data = error.model_dump(exclude_none=True)
275
+ payload: UpstreamError = {}
276
+ message = data.get("message")
277
+ if isinstance(message, str):
278
+ payload["message"] = message
279
+ resets_at = data.get("resets_at")
280
+ if isinstance(resets_at, (int, float)):
281
+ payload["resets_at"] = resets_at
282
+ resets_in_seconds = data.get("resets_in_seconds")
283
+ if isinstance(resets_in_seconds, (int, float)):
284
+ payload["resets_in_seconds"] = resets_in_seconds
285
+ return payload
@@ -12,15 +12,17 @@ from app.core.balancer import (
12
12
  select_account,
13
13
  )
14
14
  from app.core.balancer.types import UpstreamError
15
- from app.db.models import Account, AccountStatus, UsageHistory
15
+ from app.core.usage.quota import apply_usage_quota
16
+ from app.db.models import Account, UsageHistory
16
17
  from app.modules.accounts.repository import AccountsRepository
17
- from app.modules.proxy.usage_updater import UsageUpdater
18
18
  from app.modules.usage.repository import UsageRepository
19
+ from app.modules.usage.updater import UsageUpdater
19
20
 
20
21
 
21
22
  @dataclass
22
23
  class RuntimeState:
23
- reset_at: int | None = None
24
+ reset_at: float | None = None
25
+ cooldown_until: float | None = None
24
26
  last_error_at: float | None = None
25
27
  last_selected_at: float | None = None
26
28
  error_count: int = 0
@@ -100,6 +102,7 @@ class LoadBalancer:
100
102
  status=account.status,
101
103
  used_percent=None,
102
104
  reset_at=runtime.reset_at,
105
+ cooldown_until=runtime.cooldown_until,
103
106
  last_error_at=runtime.last_error_at,
104
107
  last_selected_at=runtime.last_selected_at,
105
108
  error_count=runtime.error_count,
@@ -109,6 +112,7 @@ class LoadBalancer:
109
112
  async def _sync_state(self, account: Account, state: AccountState) -> None:
110
113
  runtime = self._runtime.setdefault(account.id, RuntimeState())
111
114
  runtime.reset_at = state.reset_at
115
+ runtime.cooldown_until = state.cooldown_until
112
116
  runtime.last_error_at = state.last_error_at
113
117
  runtime.error_count = state.error_count
114
118
 
@@ -152,12 +156,16 @@ def _state_from_account(
152
156
  runtime: RuntimeState,
153
157
  ) -> AccountState:
154
158
  primary_used = primary_entry.used_percent if primary_entry else None
159
+ primary_reset = primary_entry.reset_at if primary_entry else None
160
+ primary_window_minutes = primary_entry.window_minutes if primary_entry else None
155
161
  secondary_used = secondary_entry.used_percent if secondary_entry else None
156
162
  secondary_reset = secondary_entry.reset_at if secondary_entry else None
157
163
 
158
- status, used_percent, reset_at = _apply_secondary_quota(
164
+ status, used_percent, reset_at = apply_usage_quota(
159
165
  status=account.status,
160
166
  primary_used=primary_used,
167
+ primary_reset=primary_reset,
168
+ primary_window_minutes=primary_window_minutes,
161
169
  runtime_reset=runtime.reset_at,
162
170
  secondary_used=secondary_used,
163
171
  secondary_reset=secondary_reset,
@@ -168,41 +176,9 @@ def _state_from_account(
168
176
  status=status,
169
177
  used_percent=used_percent,
170
178
  reset_at=reset_at,
179
+ cooldown_until=runtime.cooldown_until,
171
180
  last_error_at=runtime.last_error_at,
172
181
  last_selected_at=runtime.last_selected_at,
173
182
  error_count=runtime.error_count,
174
183
  deactivation_reason=account.deactivation_reason,
175
184
  )
176
-
177
-
178
- def _apply_secondary_quota(
179
- *,
180
- status: AccountStatus,
181
- primary_used: float | None,
182
- runtime_reset: int | None,
183
- secondary_used: float | None,
184
- secondary_reset: int | None,
185
- ) -> tuple[AccountStatus, float | None, int | None]:
186
- used_percent = primary_used
187
- reset_at = runtime_reset
188
-
189
- if status in (AccountStatus.DEACTIVATED, AccountStatus.PAUSED):
190
- return status, used_percent, reset_at
191
-
192
- if secondary_used is None:
193
- if status == AccountStatus.QUOTA_EXCEEDED and secondary_reset is not None:
194
- reset_at = secondary_reset
195
- return status, used_percent, reset_at
196
-
197
- if secondary_used >= 100.0:
198
- status = AccountStatus.QUOTA_EXCEEDED
199
- used_percent = 100.0
200
- if secondary_reset is not None:
201
- reset_at = secondary_reset
202
- return status, used_percent, reset_at
203
-
204
- if status == AccountStatus.QUOTA_EXCEEDED:
205
- status = AccountStatus.ACTIVE
206
- reset_at = None
207
-
208
- return status, used_percent, reset_at