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.
- app/__init__.py +1 -1
- app/core/auth/__init__.py +12 -1
- app/core/balancer/logic.py +44 -7
- app/core/clients/proxy.py +2 -4
- app/core/config/settings.py +4 -1
- app/core/plan_types.py +64 -0
- app/core/types.py +4 -2
- app/core/usage/__init__.py +5 -2
- app/core/usage/logs.py +12 -2
- app/core/usage/quota.py +64 -0
- app/core/usage/types.py +3 -2
- app/core/utils/sse.py +6 -2
- app/db/migrations/__init__.py +91 -0
- app/db/migrations/versions/__init__.py +1 -0
- app/db/migrations/versions/add_accounts_chatgpt_account_id.py +29 -0
- app/db/migrations/versions/add_accounts_reset_at.py +29 -0
- app/db/migrations/versions/add_dashboard_settings.py +31 -0
- app/db/migrations/versions/add_request_logs_reasoning_effort.py +21 -0
- app/db/migrations/versions/normalize_account_plan_types.py +17 -0
- app/db/models.py +33 -0
- app/db/session.py +85 -11
- app/dependencies.py +27 -9
- app/main.py +15 -6
- app/modules/accounts/auth_manager.py +121 -0
- app/modules/accounts/repository.py +14 -6
- app/modules/accounts/service.py +14 -9
- app/modules/health/api.py +5 -3
- app/modules/health/schemas.py +9 -0
- app/modules/oauth/service.py +9 -4
- app/modules/proxy/helpers.py +285 -0
- app/modules/proxy/load_balancer.py +86 -41
- app/modules/proxy/service.py +172 -318
- app/modules/proxy/sticky_repository.py +56 -0
- app/modules/request_logs/repository.py +6 -3
- app/modules/request_logs/schemas.py +2 -0
- app/modules/request_logs/service.py +12 -3
- app/modules/settings/__init__.py +1 -0
- app/modules/settings/api.py +37 -0
- app/modules/settings/repository.py +40 -0
- app/modules/settings/schemas.py +13 -0
- app/modules/settings/service.py +33 -0
- app/modules/shared/schemas.py +16 -2
- app/modules/usage/schemas.py +1 -0
- app/modules/usage/service.py +23 -6
- app/modules/{proxy/usage_updater.py → usage/updater.py} +37 -8
- app/static/7.css +73 -0
- app/static/index.css +33 -4
- app/static/index.html +51 -4
- app/static/index.js +254 -32
- {codex_lb-0.1.5.dist-info → codex_lb-0.3.0.dist-info}/METADATA +2 -2
- codex_lb-0.3.0.dist-info/RECORD +97 -0
- app/modules/proxy/auth_manager.py +0 -51
- codex_lb-0.1.5.dist-info/RECORD +0 -80
- {codex_lb-0.1.5.dist-info → codex_lb-0.3.0.dist-info}/WHEEL +0 -0
- {codex_lb-0.1.5.dist-info → codex_lb-0.3.0.dist-info}/entry_points.txt +0 -0
- {codex_lb-0.1.5.dist-info → codex_lb-0.3.0.dist-info}/licenses/LICENSE +0 -0
app/__init__.py
CHANGED
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=
|
|
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}"
|
app/core/balancer/logic.py
CHANGED
|
@@ -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(
|
|
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
|
|
88
|
-
|
|
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
|
|
91
|
-
|
|
92
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
72
|
-
return mapped
|
|
70
|
+
return mapped or normalized_type
|
|
73
71
|
return "upstream_error"
|
|
74
72
|
|
|
75
73
|
|
app/core/config/settings.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
4
|
-
|
|
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]
|
app/core/usage/__init__.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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),
|
app/core/usage/quota.py
ADDED
|
@@ -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
|
-
|
|
71
|
-
|
|
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.
|
|
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
|
-
|
|
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
|