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
app/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
- __version__ = "0.1.1"
1
+ __version__ = "0.2.0"
2
2
 
3
3
  from app.main import app as app
4
4
 
app/core/auth/__init__.py CHANGED
@@ -82,14 +82,25 @@ def extract_id_token_claims(id_token: str) -> IdTokenClaims:
82
82
  def claims_from_auth(auth: AuthFile) -> AccountClaims:
83
83
  claims = extract_id_token_claims(auth.tokens.id_token)
84
84
  auth_claims = claims.auth or OpenAIAuthClaims()
85
+ plan_type = auth_claims.chatgpt_plan_type or claims.chatgpt_plan_type
85
86
  return AccountClaims(
86
87
  account_id=auth.tokens.account_id or auth_claims.chatgpt_account_id or claims.chatgpt_account_id,
87
88
  email=claims.email,
88
- plan_type=auth_claims.chatgpt_plan_type or claims.chatgpt_plan_type,
89
+ plan_type=plan_type,
89
90
  )
90
91
 
91
92
 
93
+ def generate_unique_account_id(account_id: str | None, email: str | None) -> str:
94
+ if account_id and email and email != DEFAULT_EMAIL:
95
+ email_hash = hashlib.sha256(email.encode()).hexdigest()[:8]
96
+ return f"{account_id}_{email_hash}"
97
+ if account_id:
98
+ return account_id
99
+ return fallback_account_id(email)
100
+
101
+
92
102
  def fallback_account_id(email: str | None) -> str:
103
+ """Generate a fallback account ID when no OpenAI account ID is available."""
93
104
  if email and email != DEFAULT_EMAIL:
94
105
  digest = hashlib.sha256(email.encode()).hexdigest()[:12]
95
106
  return f"email_{digest}"
@@ -16,6 +16,9 @@ PERMANENT_FAILURE_CODES = {
16
16
  "account_deleted": "Account has been deleted",
17
17
  }
18
18
 
19
+ SECONDS_PER_DAY = 60 * 60 * 24
20
+ UNKNOWN_RESET_BUCKET_DAYS = 10_000
21
+
19
22
 
20
23
  @dataclass
21
24
  class AccountState:
@@ -23,6 +26,9 @@ class AccountState:
23
26
  status: AccountStatus
24
27
  used_percent: float | None = None
25
28
  reset_at: float | None = None
29
+ cooldown_until: float | None = None
30
+ secondary_used_percent: float | None = None
31
+ secondary_reset_at: int | None = None
26
32
  last_error_at: float | None = None
27
33
  last_selected_at: float | None = None
28
34
  error_count: int = 0
@@ -35,7 +41,12 @@ class SelectionResult:
35
41
  error_message: str | None
36
42
 
37
43
 
38
- def select_account(states: Iterable[AccountState], now: float | None = None) -> SelectionResult:
44
+ def select_account(
45
+ states: Iterable[AccountState],
46
+ now: float | None = None,
47
+ *,
48
+ prefer_earlier_reset: bool = False,
49
+ ) -> SelectionResult:
39
50
  current = now or time.time()
40
51
  available: list[AccountState] = []
41
52
  all_states = list(states)
@@ -59,6 +70,12 @@ def select_account(states: Iterable[AccountState], now: float | None = None) ->
59
70
  state.reset_at = None
60
71
  else:
61
72
  continue
73
+ if state.cooldown_until and current >= state.cooldown_until:
74
+ state.cooldown_until = None
75
+ state.last_error_at = None
76
+ state.error_count = 0
77
+ if state.cooldown_until and current < state.cooldown_until:
78
+ continue
62
79
  if state.error_count >= 3:
63
80
  backoff = min(300, 30 * (2 ** (state.error_count - 3)))
64
81
  if state.last_error_at and current - state.last_error_at < backoff:
@@ -82,14 +99,29 @@ def select_account(states: Iterable[AccountState], now: float | None = None) ->
82
99
  if reset_candidates:
83
100
  wait_seconds = max(0, min(reset_candidates) - int(current))
84
101
  return SelectionResult(None, f"Rate limit exceeded. Try again in {wait_seconds:.0f}s")
102
+ cooldowns = [s.cooldown_until for s in all_states if s.cooldown_until and s.cooldown_until > current]
103
+ if cooldowns:
104
+ wait_seconds = max(0.0, min(cooldowns) - current)
105
+ return SelectionResult(None, f"Rate limit exceeded. Try again in {wait_seconds:.0f}s")
85
106
  return SelectionResult(None, "No available accounts")
86
107
 
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
108
+ def _usage_sort_key(state: AccountState) -> tuple[float, float, float, str]:
109
+ primary_used = state.used_percent if state.used_percent is not None else 0.0
110
+ secondary_used = state.secondary_used_percent if state.secondary_used_percent is not None else primary_used
89
111
  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)
112
+ return secondary_used, primary_used, last_selected, state.account_id
113
+
114
+ def _reset_first_sort_key(state: AccountState) -> tuple[int, float, float, float, str]:
115
+ reset_bucket_days = UNKNOWN_RESET_BUCKET_DAYS
116
+ if state.secondary_reset_at is not None:
117
+ reset_bucket_days = max(
118
+ 0,
119
+ int((state.secondary_reset_at - current) // SECONDS_PER_DAY),
120
+ )
121
+ secondary_used, primary_used, last_selected, account_id = _usage_sort_key(state)
122
+ return reset_bucket_days, secondary_used, primary_used, last_selected, account_id
123
+
124
+ selected = min(available, key=_reset_first_sort_key if prefer_earlier_reset else _usage_sort_key)
93
125
  return SelectionResult(selected, None)
94
126
 
95
127
 
@@ -97,11 +129,16 @@ def handle_rate_limit(state: AccountState, error: UpstreamError) -> None:
97
129
  state.status = AccountStatus.RATE_LIMITED
98
130
  state.error_count += 1
99
131
  state.last_error_at = time.time()
132
+
133
+ reset_at = _extract_reset_at(error)
134
+ if reset_at is not None:
135
+ state.reset_at = reset_at
136
+
100
137
  message = error.get("message")
101
138
  delay = parse_retry_after(message) if message else None
102
139
  if delay is None:
103
140
  delay = backoff_seconds(state.error_count)
104
- state.reset_at = time.time() + delay
141
+ state.cooldown_until = time.time() + delay
105
142
 
106
143
 
107
144
  def handle_quota_exceeded(state: AccountState, error: UpstreamError) -> None:
app/core/clients/proxy.py CHANGED
@@ -18,7 +18,6 @@ IGNORE_INBOUND_HEADERS = {"authorization", "chatgpt-account-id", "content-length
18
18
 
19
19
  _ERROR_TYPE_CODE_MAP = {
20
20
  "rate_limit_exceeded": "rate_limit_exceeded",
21
- "usage_limit_reached": "rate_limit_exceeded",
22
21
  "usage_not_included": "usage_not_included",
23
22
  "insufficient_quota": "insufficient_quota",
24
23
  "quota_exceeded": "quota_exceeded",
@@ -64,12 +63,11 @@ def _normalize_error_code(code: str | None, error_type: str | None) -> str:
64
63
  if code:
65
64
  normalized_code = code.lower()
66
65
  mapped = _ERROR_TYPE_CODE_MAP.get(normalized_code)
67
- return mapped or code
66
+ return mapped or normalized_code
68
67
  normalized_type = error_type.lower() if error_type else None
69
68
  if normalized_type:
70
69
  mapped = _ERROR_TYPE_CODE_MAP.get(normalized_type)
71
- if mapped:
72
- return mapped
70
+ return mapped or normalized_type
73
71
  return "upstream_error"
74
72
 
75
73
 
@@ -39,6 +39,9 @@ class Settings(BaseSettings):
39
39
  usage_refresh_enabled: bool = True
40
40
  usage_refresh_interval_seconds: int = 60
41
41
  encryption_key_file: Path = DEFAULT_ENCRYPTION_KEY_FILE
42
+ database_migrations_fail_fast: bool = True
43
+ log_proxy_request_shape: bool = False
44
+ log_proxy_request_shape_raw_cache_key: bool = False
42
45
 
43
46
  @field_validator("database_url")
44
47
  @classmethod
@@ -61,7 +64,7 @@ class Settings(BaseSettings):
61
64
  return value.expanduser()
62
65
  if isinstance(value, str):
63
66
  return Path(value).expanduser()
64
- return value
67
+ raise TypeError("encryption_key_file must be a path")
65
68
 
66
69
 
67
70
  @lru_cache(maxsize=1)
app/core/plan_types.py ADDED
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Final
4
+
5
+ ACCOUNT_PLAN_TYPES: Final[set[str]] = {
6
+ "free",
7
+ "plus",
8
+ "pro",
9
+ "team",
10
+ "business",
11
+ "enterprise",
12
+ "edu",
13
+ }
14
+
15
+ RATE_LIMIT_PLAN_TYPES: Final[set[str]] = {
16
+ *ACCOUNT_PLAN_TYPES,
17
+ "guest",
18
+ "go",
19
+ "free_workspace",
20
+ "education",
21
+ "quorum",
22
+ "k12",
23
+ }
24
+
25
+
26
+ def _clean_plan_type(value: str | None) -> str | None:
27
+ if value is None:
28
+ return None
29
+ cleaned = value.strip()
30
+ return cleaned or None
31
+
32
+
33
+ def normalize_account_plan_type(value: str | None) -> str | None:
34
+ cleaned = _clean_plan_type(value)
35
+ if not cleaned:
36
+ return None
37
+ normalized = cleaned.lower()
38
+ return normalized if normalized in ACCOUNT_PLAN_TYPES else None
39
+
40
+
41
+ def canonicalize_account_plan_type(value: str | None) -> str | None:
42
+ cleaned = _clean_plan_type(value)
43
+ if not cleaned:
44
+ return None
45
+ normalized = cleaned.lower()
46
+ if normalized in ACCOUNT_PLAN_TYPES:
47
+ return normalized
48
+ return cleaned
49
+
50
+
51
+ def coerce_account_plan_type(value: str | None, default: str) -> str:
52
+ cleaned = _clean_plan_type(value)
53
+ if cleaned is None:
54
+ return default
55
+ canonical = canonicalize_account_plan_type(cleaned)
56
+ return canonical if canonical is not None else default
57
+
58
+
59
+ def normalize_rate_limit_plan_type(value: str | None) -> str | None:
60
+ cleaned = _clean_plan_type(value)
61
+ if not cleaned:
62
+ return None
63
+ normalized = cleaned.lower()
64
+ return normalized if normalized in RATE_LIMIT_PLAN_TYPES else None
app/core/types.py CHANGED
@@ -1,4 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- type JsonValue = bool | int | float | str | None | list[JsonValue] | dict[str, JsonValue]
4
- type JsonObject = dict[str, JsonValue]
3
+ from collections.abc import Mapping
4
+
5
+ type JsonValue = bool | int | float | str | None | list[JsonValue] | Mapping[str, JsonValue]
6
+ type JsonObject = Mapping[str, JsonValue]
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  from typing import Iterable, Mapping
4
4
 
5
+ from app.core.plan_types import normalize_account_plan_type
5
6
  from app.core.usage.types import (
6
7
  UsageCostSummary,
7
8
  UsageHistoryPayload,
@@ -16,12 +17,14 @@ from app.db.models import Account
16
17
  PLAN_CAPACITY_CREDITS_PRIMARY = {
17
18
  "plus": 225.0,
18
19
  "business": 225.0,
20
+ "team": 225.0,
19
21
  "pro": 1500.0,
20
22
  }
21
23
 
22
24
  PLAN_CAPACITY_CREDITS_SECONDARY = {
23
25
  "plus": 7560.0,
24
26
  "business": 7560.0,
27
+ "team": 7560.0,
25
28
  "pro": 50400.0,
26
29
  }
27
30
 
@@ -134,9 +137,9 @@ def summarize_usage_window(
134
137
 
135
138
 
136
139
  def capacity_for_plan(plan_type: str | None, window: str) -> float | None:
137
- if not plan_type:
140
+ normalized = normalize_account_plan_type(plan_type)
141
+ if not normalized:
138
142
  return None
139
- normalized = plan_type.lower()
140
143
  window_key = _normalize_window_key(window)
141
144
  if window_key == "primary":
142
145
  return PLAN_CAPACITY_CREDITS_PRIMARY.get(normalized)
app/core/usage/logs.py CHANGED
@@ -13,6 +13,17 @@ class RequestLogLike(Protocol):
13
13
  reasoning_tokens: int | None
14
14
 
15
15
 
16
+ def cached_input_tokens_from_log(log: RequestLogLike) -> int | None:
17
+ cached_tokens = log.cached_input_tokens
18
+ if cached_tokens is None:
19
+ return None
20
+ cached_tokens = max(0, int(cached_tokens))
21
+ input_tokens = log.input_tokens
22
+ if input_tokens is not None:
23
+ cached_tokens = min(cached_tokens, int(input_tokens))
24
+ return cached_tokens
25
+
26
+
16
27
  def usage_tokens_from_log(log: RequestLogLike) -> UsageTokens | None:
17
28
  input_tokens = log.input_tokens
18
29
  if input_tokens is None:
@@ -20,8 +31,7 @@ def usage_tokens_from_log(log: RequestLogLike) -> UsageTokens | None:
20
31
  output_tokens = log.output_tokens if log.output_tokens is not None else log.reasoning_tokens
21
32
  if output_tokens is None:
22
33
  return None
23
- cached_tokens = log.cached_input_tokens or 0
24
- cached_tokens = max(0, min(cached_tokens, input_tokens))
34
+ cached_tokens = cached_input_tokens_from_log(log) or 0
25
35
  return UsageTokens(
26
36
  input_tokens=float(input_tokens),
27
37
  output_tokens=float(output_tokens),
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+
5
+ from app.core import usage as usage_core
6
+ from app.db.models import AccountStatus
7
+
8
+
9
+ def apply_usage_quota(
10
+ *,
11
+ status: AccountStatus,
12
+ primary_used: float | None,
13
+ primary_reset: int | None,
14
+ primary_window_minutes: int | None,
15
+ runtime_reset: float | None,
16
+ secondary_used: float | None,
17
+ secondary_reset: int | None,
18
+ ) -> tuple[AccountStatus, float | None, float | None]:
19
+ used_percent = primary_used
20
+ reset_at = runtime_reset
21
+
22
+ if status in (AccountStatus.DEACTIVATED, AccountStatus.PAUSED):
23
+ return status, used_percent, reset_at
24
+
25
+ if secondary_used is not None:
26
+ if secondary_used >= 100.0:
27
+ status = AccountStatus.QUOTA_EXCEEDED
28
+ used_percent = 100.0
29
+ if secondary_reset is not None:
30
+ reset_at = secondary_reset
31
+ return status, used_percent, reset_at
32
+ if status == AccountStatus.QUOTA_EXCEEDED:
33
+ if runtime_reset and runtime_reset > time.time():
34
+ reset_at = runtime_reset
35
+ else:
36
+ status = AccountStatus.ACTIVE
37
+ reset_at = None
38
+ elif status == AccountStatus.QUOTA_EXCEEDED and secondary_reset is not None:
39
+ reset_at = secondary_reset
40
+
41
+ if primary_used is not None:
42
+ if primary_used >= 100.0:
43
+ status = AccountStatus.RATE_LIMITED
44
+ used_percent = 100.0
45
+ if primary_reset is not None:
46
+ reset_at = primary_reset
47
+ else:
48
+ reset_at = _fallback_primary_reset(primary_window_minutes) or reset_at
49
+ return status, used_percent, reset_at
50
+ if status == AccountStatus.RATE_LIMITED:
51
+ if runtime_reset and runtime_reset > time.time():
52
+ reset_at = runtime_reset
53
+ else:
54
+ status = AccountStatus.ACTIVE
55
+ reset_at = None
56
+
57
+ return status, used_percent, reset_at
58
+
59
+
60
+ def _fallback_primary_reset(primary_window_minutes: int | None) -> float | None:
61
+ window_minutes = primary_window_minutes or usage_core.default_window_minutes("primary")
62
+ if not window_minutes:
63
+ return None
64
+ return time.time() + float(window_minutes) * 60.0
app/core/usage/types.py CHANGED
@@ -67,8 +67,9 @@ class UsageCostSummary:
67
67
  class UsageMetricsSummary:
68
68
  requests_7d: int | None
69
69
  tokens_secondary_window: int | None
70
- error_rate_7d: float | None
71
- top_error: str | None
70
+ cached_tokens_secondary_window: int | None = None
71
+ error_rate_7d: float | None = None
72
+ top_error: str | None = None
72
73
 
73
74
 
74
75
  @dataclass(frozen=True)
app/core/utils/sse.py CHANGED
@@ -1,11 +1,15 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ from collections.abc import Mapping
4
5
 
5
- from app.core.types import JsonObject
6
+ from app.core.errors import ResponseFailedEvent
7
+ from app.core.types import JsonValue
6
8
 
9
+ type JsonPayload = Mapping[str, JsonValue] | ResponseFailedEvent
7
10
 
8
- def format_sse_event(payload: JsonObject) -> str:
11
+
12
+ def format_sse_event(payload: JsonPayload) -> str:
9
13
  data = json.dumps(payload, ensure_ascii=True, separators=(",", ":"))
10
14
  event_type = payload.get("type")
11
15
  if isinstance(event_type, str) and event_type:
@@ -0,0 +1,91 @@
1
+ from __future__ import annotations
2
+
3
+ from contextlib import asynccontextmanager
4
+ from dataclasses import dataclass
5
+ from datetime import datetime, timezone
6
+ from typing import Awaitable, Callable, Final
7
+
8
+ from sqlalchemy import text
9
+ from sqlalchemy.ext.asyncio import AsyncSession
10
+
11
+ from app.db.migrations.versions import (
12
+ add_accounts_chatgpt_account_id,
13
+ add_accounts_reset_at,
14
+ add_dashboard_settings,
15
+ add_request_logs_reasoning_effort,
16
+ normalize_account_plan_types,
17
+ )
18
+
19
+ _CREATE_MIGRATIONS_TABLE = """
20
+ CREATE TABLE IF NOT EXISTS schema_migrations (
21
+ name TEXT PRIMARY KEY,
22
+ applied_at TEXT NOT NULL
23
+ )
24
+ """
25
+
26
+ _INSERT_MIGRATION = """
27
+ INSERT INTO schema_migrations (name, applied_at)
28
+ VALUES (:name, :applied_at)
29
+ ON CONFLICT(name) DO NOTHING
30
+ RETURNING name
31
+ """
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class Migration:
36
+ name: str
37
+ run: Callable[[AsyncSession], Awaitable[None]]
38
+
39
+
40
+ MIGRATIONS: Final[tuple[Migration, ...]] = (
41
+ Migration("001_normalize_account_plan_types", normalize_account_plan_types.run),
42
+ Migration("002_add_request_logs_reasoning_effort", add_request_logs_reasoning_effort.run),
43
+ Migration("003_add_accounts_reset_at", add_accounts_reset_at.run),
44
+ Migration("004_add_accounts_chatgpt_account_id", add_accounts_chatgpt_account_id.run),
45
+ Migration("005_add_dashboard_settings", add_dashboard_settings.run),
46
+ )
47
+
48
+
49
+ async def run_migrations(session: AsyncSession) -> int:
50
+ await _ensure_schema_migrations(session)
51
+ applied_count = 0
52
+ for migration in MIGRATIONS:
53
+ applied_now = await _apply_migration(session, migration)
54
+ if applied_now:
55
+ applied_count += 1
56
+ return applied_count
57
+
58
+
59
+ async def _apply_migration(session: AsyncSession, migration: Migration) -> bool:
60
+ async with _migration_transaction(session):
61
+ result = await session.execute(
62
+ text(_INSERT_MIGRATION),
63
+ {
64
+ "name": migration.name,
65
+ "applied_at": _utcnow_iso(),
66
+ },
67
+ )
68
+ inserted = result.scalar_one_or_none()
69
+ if inserted is None:
70
+ return False
71
+ await migration.run(session)
72
+ return True
73
+
74
+
75
+ async def _ensure_schema_migrations(session: AsyncSession) -> None:
76
+ async with _migration_transaction(session):
77
+ await session.execute(text(_CREATE_MIGRATIONS_TABLE))
78
+
79
+
80
+ @asynccontextmanager
81
+ async def _migration_transaction(session: AsyncSession):
82
+ if session.in_transaction():
83
+ async with session.begin_nested():
84
+ yield
85
+ else:
86
+ async with session.begin():
87
+ yield
88
+
89
+
90
+ def _utcnow_iso() -> str:
91
+ return datetime.now(timezone.utc).isoformat()
@@ -0,0 +1 @@
1
+ from __future__ import annotations
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ from sqlalchemy import text
4
+ from sqlalchemy.ext.asyncio import AsyncSession
5
+
6
+
7
+ async def run(session: AsyncSession) -> None:
8
+ bind = session.get_bind()
9
+ dialect = getattr(getattr(bind, "dialect", None), "name", None)
10
+ if dialect == "sqlite":
11
+ await _sqlite_add_column_if_missing(session, "accounts", "chatgpt_account_id", "VARCHAR")
12
+ elif dialect == "postgresql":
13
+ await session.execute(
14
+ text("ALTER TABLE accounts ADD COLUMN IF NOT EXISTS chatgpt_account_id VARCHAR"),
15
+ )
16
+
17
+
18
+ async def _sqlite_add_column_if_missing(
19
+ session: AsyncSession,
20
+ table: str,
21
+ column: str,
22
+ column_type: str,
23
+ ) -> None:
24
+ result = await session.execute(text(f"PRAGMA table_info({table})"))
25
+ rows = result.fetchall()
26
+ existing = {row[1] for row in rows if len(row) > 1}
27
+ if column in existing:
28
+ return
29
+ await session.execute(text(f"ALTER TABLE {table} ADD COLUMN {column} {column_type}"))
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ from sqlalchemy import text
4
+ from sqlalchemy.ext.asyncio import AsyncSession
5
+
6
+
7
+ async def run(session: AsyncSession) -> None:
8
+ bind = session.get_bind()
9
+ dialect = getattr(getattr(bind, "dialect", None), "name", None)
10
+ if dialect == "sqlite":
11
+ await _sqlite_add_column_if_missing(session, "accounts", "reset_at", "INTEGER")
12
+ elif dialect == "postgresql":
13
+ await session.execute(
14
+ text("ALTER TABLE accounts ADD COLUMN IF NOT EXISTS reset_at INTEGER"),
15
+ )
16
+
17
+
18
+ async def _sqlite_add_column_if_missing(
19
+ session: AsyncSession,
20
+ table: str,
21
+ column: str,
22
+ column_type: str,
23
+ ) -> None:
24
+ result = await session.execute(text(f"PRAGMA table_info({table})"))
25
+ rows = result.fetchall()
26
+ existing = {row[1] for row in rows if len(row) > 1}
27
+ if column in existing:
28
+ return
29
+ await session.execute(text(f"ALTER TABLE {table} ADD COLUMN {column} {column_type}"))
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ from sqlalchemy import inspect
4
+ from sqlalchemy.ext.asyncio import AsyncSession
5
+ from sqlalchemy.orm import Session
6
+
7
+ from app.db.models import DashboardSettings
8
+
9
+
10
+ def _settings_table_exists(session: Session) -> bool:
11
+ inspector = inspect(session.connection())
12
+ return inspector.has_table("dashboard_settings")
13
+
14
+
15
+ async def run(session: AsyncSession) -> None:
16
+ exists = await session.run_sync(_settings_table_exists)
17
+ if not exists:
18
+ return
19
+
20
+ row = await session.get(DashboardSettings, 1)
21
+ if row is not None:
22
+ return
23
+
24
+ session.add(
25
+ DashboardSettings(
26
+ id=1,
27
+ sticky_threads_enabled=False,
28
+ prefer_earlier_reset_accounts=False,
29
+ )
30
+ )
31
+ await session.flush()
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ from sqlalchemy import inspect, text
4
+ from sqlalchemy.ext.asyncio import AsyncSession
5
+ from sqlalchemy.orm import Session
6
+
7
+
8
+ def _request_logs_column_state(session: Session) -> tuple[bool, bool]:
9
+ conn = session.connection()
10
+ inspector = inspect(conn)
11
+ if not inspector.has_table("request_logs"):
12
+ return False, False
13
+ columns = {column["name"] for column in inspector.get_columns("request_logs")}
14
+ return True, "reasoning_effort" in columns
15
+
16
+
17
+ async def run(session: AsyncSession) -> None:
18
+ has_table, has_column = await session.run_sync(_request_logs_column_state)
19
+ if not has_table or has_column:
20
+ return
21
+ await session.execute(text("ALTER TABLE request_logs ADD COLUMN reasoning_effort VARCHAR"))
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ from sqlalchemy import select
4
+ from sqlalchemy.ext.asyncio import AsyncSession
5
+
6
+ from app.core.auth import DEFAULT_PLAN
7
+ from app.core.plan_types import coerce_account_plan_type
8
+ from app.db.models import Account
9
+
10
+
11
+ async def run(session: AsyncSession) -> None:
12
+ result = await session.execute(select(Account))
13
+ accounts = list(result.scalars().all())
14
+ for account in accounts:
15
+ coerced = coerce_account_plan_type(account.plan_type, DEFAULT_PLAN)
16
+ if account.plan_type != coerced:
17
+ account.plan_type = coerced