codex-lb 0.1.5__py3-none-any.whl → 0.3.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.
Files changed (56) hide show
  1. app/__init__.py +1 -1
  2. app/core/auth/__init__.py +12 -1
  3. app/core/balancer/logic.py +44 -7
  4. app/core/clients/proxy.py +2 -4
  5. app/core/config/settings.py +4 -1
  6. app/core/plan_types.py +64 -0
  7. app/core/types.py +4 -2
  8. app/core/usage/__init__.py +5 -2
  9. app/core/usage/logs.py +12 -2
  10. app/core/usage/quota.py +64 -0
  11. app/core/usage/types.py +3 -2
  12. app/core/utils/sse.py +6 -2
  13. app/db/migrations/__init__.py +91 -0
  14. app/db/migrations/versions/__init__.py +1 -0
  15. app/db/migrations/versions/add_accounts_chatgpt_account_id.py +29 -0
  16. app/db/migrations/versions/add_accounts_reset_at.py +29 -0
  17. app/db/migrations/versions/add_dashboard_settings.py +31 -0
  18. app/db/migrations/versions/add_request_logs_reasoning_effort.py +21 -0
  19. app/db/migrations/versions/normalize_account_plan_types.py +17 -0
  20. app/db/models.py +33 -0
  21. app/db/session.py +85 -11
  22. app/dependencies.py +27 -9
  23. app/main.py +15 -6
  24. app/modules/accounts/auth_manager.py +121 -0
  25. app/modules/accounts/repository.py +14 -6
  26. app/modules/accounts/service.py +14 -9
  27. app/modules/health/api.py +5 -3
  28. app/modules/health/schemas.py +9 -0
  29. app/modules/oauth/service.py +9 -4
  30. app/modules/proxy/helpers.py +285 -0
  31. app/modules/proxy/load_balancer.py +86 -41
  32. app/modules/proxy/service.py +172 -318
  33. app/modules/proxy/sticky_repository.py +56 -0
  34. app/modules/request_logs/repository.py +6 -3
  35. app/modules/request_logs/schemas.py +2 -0
  36. app/modules/request_logs/service.py +12 -3
  37. app/modules/settings/__init__.py +1 -0
  38. app/modules/settings/api.py +37 -0
  39. app/modules/settings/repository.py +40 -0
  40. app/modules/settings/schemas.py +13 -0
  41. app/modules/settings/service.py +33 -0
  42. app/modules/shared/schemas.py +16 -2
  43. app/modules/usage/schemas.py +1 -0
  44. app/modules/usage/service.py +23 -6
  45. app/modules/{proxy/usage_updater.py → usage/updater.py} +37 -8
  46. app/static/7.css +73 -0
  47. app/static/index.css +33 -4
  48. app/static/index.html +51 -4
  49. app/static/index.js +254 -32
  50. {codex_lb-0.1.5.dist-info → codex_lb-0.3.0.dist-info}/METADATA +2 -2
  51. codex_lb-0.3.0.dist-info/RECORD +97 -0
  52. app/modules/proxy/auth_manager.py +0 -51
  53. codex_lb-0.1.5.dist-info/RECORD +0 -80
  54. {codex_lb-0.1.5.dist-info → codex_lb-0.3.0.dist-info}/WHEEL +0 -0
  55. {codex_lb-0.1.5.dist-info → codex_lb-0.3.0.dist-info}/entry_points.txt +0 -0
  56. {codex_lb-0.1.5.dist-info → codex_lb-0.3.0.dist-info}/licenses/LICENSE +0 -0
@@ -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
@@ -6,21 +6,25 @@ from typing import Iterable
6
6
 
7
7
  from app.core.balancer import (
8
8
  AccountState,
9
+ SelectionResult,
9
10
  handle_permanent_failure,
10
11
  handle_quota_exceeded,
11
12
  handle_rate_limit,
12
13
  select_account,
13
14
  )
14
15
  from app.core.balancer.types import UpstreamError
15
- from app.db.models import Account, AccountStatus, UsageHistory
16
+ from app.core.usage.quota import apply_usage_quota
17
+ from app.db.models import Account, UsageHistory
16
18
  from app.modules.accounts.repository import AccountsRepository
17
- from app.modules.proxy.usage_updater import UsageUpdater
19
+ from app.modules.proxy.sticky_repository import StickySessionsRepository
18
20
  from app.modules.usage.repository import UsageRepository
21
+ from app.modules.usage.updater import UsageUpdater
19
22
 
20
23
 
21
24
  @dataclass
22
25
  class RuntimeState:
23
26
  reset_at: float | None = None
27
+ cooldown_until: float | None = None
24
28
  last_error_at: float | None = None
25
29
  last_selected_at: float | None = None
26
30
  error_count: int = 0
@@ -33,13 +37,25 @@ class AccountSelection:
33
37
 
34
38
 
35
39
  class LoadBalancer:
36
- def __init__(self, accounts_repo: AccountsRepository, usage_repo: UsageRepository) -> None:
40
+ def __init__(
41
+ self,
42
+ accounts_repo: AccountsRepository,
43
+ usage_repo: UsageRepository,
44
+ sticky_repo: StickySessionsRepository | None = None,
45
+ ) -> None:
37
46
  self._accounts_repo = accounts_repo
38
47
  self._usage_repo = usage_repo
39
48
  self._usage_updater = UsageUpdater(usage_repo, accounts_repo)
49
+ self._sticky_repo = sticky_repo
40
50
  self._runtime: dict[str, RuntimeState] = {}
41
51
 
42
- async def select_account(self) -> AccountSelection:
52
+ async def select_account(
53
+ self,
54
+ sticky_key: str | None = None,
55
+ *,
56
+ reallocate_sticky: bool = False,
57
+ prefer_earlier_reset_accounts: bool = False,
58
+ ) -> AccountSelection:
43
59
  accounts = await self._accounts_repo.list_accounts()
44
60
  latest_primary = await self._usage_repo.latest_by_account()
45
61
  await self._usage_updater.refresh_accounts(accounts, latest_primary)
@@ -53,7 +69,13 @@ class LoadBalancer:
53
69
  runtime=self._runtime,
54
70
  )
55
71
 
56
- result = select_account(states)
72
+ result = await self._select_with_stickiness(
73
+ states=states,
74
+ account_map=account_map,
75
+ sticky_key=sticky_key,
76
+ reallocate_sticky=reallocate_sticky,
77
+ prefer_earlier_reset_accounts=prefer_earlier_reset_accounts,
78
+ )
57
79
  for state in states:
58
80
  account = account_map.get(state.account_id)
59
81
  if account:
@@ -72,6 +94,39 @@ class LoadBalancer:
72
94
  return AccountSelection(account=None, error_message=result.error_message)
73
95
  return AccountSelection(account=selected, error_message=None)
74
96
 
97
+ async def _select_with_stickiness(
98
+ self,
99
+ *,
100
+ states: list[AccountState],
101
+ account_map: dict[str, Account],
102
+ sticky_key: str | None,
103
+ reallocate_sticky: bool,
104
+ prefer_earlier_reset_accounts: bool,
105
+ ) -> SelectionResult:
106
+ if not sticky_key or not self._sticky_repo:
107
+ return select_account(states, prefer_earlier_reset=prefer_earlier_reset_accounts)
108
+
109
+ if reallocate_sticky:
110
+ chosen = select_account(states, prefer_earlier_reset=prefer_earlier_reset_accounts)
111
+ if chosen.account is not None and chosen.account.account_id in account_map:
112
+ await self._sticky_repo.upsert(sticky_key, chosen.account.account_id)
113
+ return chosen
114
+
115
+ existing = await self._sticky_repo.get_account_id(sticky_key)
116
+ if existing:
117
+ pinned = next((state for state in states if state.account_id == existing), None)
118
+ if pinned is None:
119
+ await self._sticky_repo.delete(sticky_key)
120
+ else:
121
+ pinned_result = select_account([pinned], prefer_earlier_reset=prefer_earlier_reset_accounts)
122
+ if pinned_result.account is not None:
123
+ return pinned_result
124
+
125
+ chosen = select_account(states, prefer_earlier_reset=prefer_earlier_reset_accounts)
126
+ if chosen.account is not None and chosen.account.account_id in account_map:
127
+ await self._sticky_repo.upsert(sticky_key, chosen.account.account_id)
128
+ return chosen
129
+
75
130
  async def mark_rate_limit(self, account: Account, error: UpstreamError) -> None:
76
131
  state = self._state_for(account)
77
132
  handle_rate_limit(state, error)
@@ -100,6 +155,9 @@ class LoadBalancer:
100
155
  status=account.status,
101
156
  used_percent=None,
102
157
  reset_at=runtime.reset_at,
158
+ cooldown_until=runtime.cooldown_until,
159
+ secondary_used_percent=None,
160
+ secondary_reset_at=None,
103
161
  last_error_at=runtime.last_error_at,
104
162
  last_selected_at=runtime.last_selected_at,
105
163
  error_count=runtime.error_count,
@@ -109,17 +167,25 @@ class LoadBalancer:
109
167
  async def _sync_state(self, account: Account, state: AccountState) -> None:
110
168
  runtime = self._runtime.setdefault(account.id, RuntimeState())
111
169
  runtime.reset_at = state.reset_at
170
+ runtime.cooldown_until = state.cooldown_until
112
171
  runtime.last_error_at = state.last_error_at
113
172
  runtime.error_count = state.error_count
114
173
 
115
- if account.status != state.status or account.deactivation_reason != state.deactivation_reason:
174
+ reset_at_int = int(state.reset_at) if state.reset_at else None
175
+ status_changed = account.status != state.status
176
+ reason_changed = account.deactivation_reason != state.deactivation_reason
177
+ reset_changed = account.reset_at != reset_at_int
178
+
179
+ if status_changed or reason_changed or reset_changed:
116
180
  await self._accounts_repo.update_status(
117
181
  account.id,
118
182
  state.status,
119
183
  state.deactivation_reason,
184
+ reset_at_int,
120
185
  )
121
186
  account.status = state.status
122
187
  account.deactivation_reason = state.deactivation_reason
188
+ account.reset_at = reset_at_int
123
189
 
124
190
 
125
191
  def _build_states(
@@ -152,13 +218,22 @@ def _state_from_account(
152
218
  runtime: RuntimeState,
153
219
  ) -> AccountState:
154
220
  primary_used = primary_entry.used_percent if primary_entry else None
221
+ primary_reset = primary_entry.reset_at if primary_entry else None
222
+ primary_window_minutes = primary_entry.window_minutes if primary_entry else None
155
223
  secondary_used = secondary_entry.used_percent if secondary_entry else None
156
224
  secondary_reset = secondary_entry.reset_at if secondary_entry else None
157
225
 
158
- status, used_percent, reset_at = _apply_secondary_quota(
226
+ # Use account.reset_at from DB as the authoritative source for runtime reset
227
+ # This survives across requests since LoadBalancer is instantiated per-request
228
+ db_reset_at = float(account.reset_at) if account.reset_at else None
229
+ effective_runtime_reset = db_reset_at or runtime.reset_at
230
+
231
+ status, used_percent, reset_at = apply_usage_quota(
159
232
  status=account.status,
160
233
  primary_used=primary_used,
161
- runtime_reset=runtime.reset_at,
234
+ primary_reset=primary_reset,
235
+ primary_window_minutes=primary_window_minutes,
236
+ runtime_reset=effective_runtime_reset,
162
237
  secondary_used=secondary_used,
163
238
  secondary_reset=secondary_reset,
164
239
  )
@@ -168,41 +243,11 @@ def _state_from_account(
168
243
  status=status,
169
244
  used_percent=used_percent,
170
245
  reset_at=reset_at,
246
+ cooldown_until=runtime.cooldown_until,
247
+ secondary_used_percent=secondary_used,
248
+ secondary_reset_at=secondary_reset,
171
249
  last_error_at=runtime.last_error_at,
172
250
  last_selected_at=runtime.last_selected_at,
173
251
  error_count=runtime.error_count,
174
252
  deactivation_reason=account.deactivation_reason,
175
253
  )
176
-
177
-
178
- def _apply_secondary_quota(
179
- *,
180
- status: AccountStatus,
181
- primary_used: float | None,
182
- runtime_reset: float | None,
183
- secondary_used: float | None,
184
- secondary_reset: int | None,
185
- ) -> tuple[AccountStatus, float | None, float | 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