codex-lb 0.3.1__py3-none-any.whl → 0.4.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.
@@ -3,12 +3,12 @@ from __future__ import annotations
3
3
  from datetime import datetime
4
4
 
5
5
  import anyio
6
- from sqlalchemy import and_, select
6
+ from sqlalchemy import String, and_, cast, or_, select
7
7
  from sqlalchemy.ext.asyncio import AsyncSession
8
8
 
9
9
  from app.core.utils.request_id import ensure_request_id
10
10
  from app.core.utils.time import utcnow
11
- from app.db.models import RequestLog
11
+ from app.db.models import Account, RequestLog
12
12
 
13
13
 
14
14
  class RequestLogsRepository:
@@ -63,35 +63,147 @@ class RequestLogsRepository:
63
63
  async def list_recent(
64
64
  self,
65
65
  limit: int = 50,
66
+ offset: int = 0,
67
+ search: str | None = None,
66
68
  since: datetime | None = None,
67
69
  until: datetime | None = None,
68
- account_id: str | None = None,
69
- model: str | None = None,
70
- status: str | None = None,
71
- error_codes: list[str] | None = None,
70
+ account_ids: list[str] | None = None,
71
+ model_options: list[tuple[str, str | None]] | None = None,
72
+ models: list[str] | None = None,
73
+ reasoning_efforts: list[str] | None = None,
74
+ include_success: bool = True,
75
+ include_error_other: bool = True,
76
+ error_codes_in: list[str] | None = None,
77
+ error_codes_excluding: list[str] | None = None,
72
78
  ) -> list[RequestLog]:
73
79
  conditions = []
74
80
  if since is not None:
75
81
  conditions.append(RequestLog.requested_at >= since)
76
82
  if until is not None:
77
83
  conditions.append(RequestLog.requested_at <= until)
78
- if account_id is not None:
79
- conditions.append(RequestLog.account_id == account_id)
80
- if model is not None:
81
- conditions.append(RequestLog.model == model)
82
- if status is not None:
83
- conditions.append(RequestLog.status == status)
84
- if error_codes:
85
- conditions.append(RequestLog.error_code.in_(error_codes))
86
-
87
- stmt = select(RequestLog).order_by(RequestLog.requested_at.desc())
84
+ if account_ids:
85
+ conditions.append(RequestLog.account_id.in_(account_ids))
86
+
87
+ if model_options:
88
+ pair_conditions = []
89
+ for model, effort in model_options:
90
+ base = (model or "").strip()
91
+ if not base:
92
+ continue
93
+ if effort is None:
94
+ pair_conditions.append(and_(RequestLog.model == base, RequestLog.reasoning_effort.is_(None)))
95
+ else:
96
+ pair_conditions.append(and_(RequestLog.model == base, RequestLog.reasoning_effort == effort))
97
+ if pair_conditions:
98
+ conditions.append(or_(*pair_conditions))
99
+ else:
100
+ if models:
101
+ conditions.append(RequestLog.model.in_(models))
102
+ if reasoning_efforts:
103
+ conditions.append(RequestLog.reasoning_effort.in_(reasoning_efforts))
104
+
105
+ status_conditions = []
106
+ if include_success:
107
+ status_conditions.append(RequestLog.status == "success")
108
+ if error_codes_in:
109
+ status_conditions.append(and_(RequestLog.status == "error", RequestLog.error_code.in_(error_codes_in)))
110
+ if include_error_other:
111
+ error_clause = [RequestLog.status == "error"]
112
+ if error_codes_excluding:
113
+ error_clause.append(
114
+ or_(
115
+ RequestLog.error_code.is_(None),
116
+ ~RequestLog.error_code.in_(error_codes_excluding),
117
+ )
118
+ )
119
+ status_conditions.append(and_(*error_clause))
120
+ if status_conditions:
121
+ conditions.append(or_(*status_conditions))
122
+ if search:
123
+ search_pattern = f"%{search}%"
124
+ conditions.append(
125
+ or_(
126
+ RequestLog.account_id.ilike(search_pattern),
127
+ Account.email.ilike(search_pattern),
128
+ RequestLog.request_id.ilike(search_pattern),
129
+ RequestLog.model.ilike(search_pattern),
130
+ RequestLog.reasoning_effort.ilike(search_pattern),
131
+ RequestLog.status.ilike(search_pattern),
132
+ RequestLog.error_code.ilike(search_pattern),
133
+ RequestLog.error_message.ilike(search_pattern),
134
+ cast(RequestLog.requested_at, String).ilike(search_pattern),
135
+ cast(RequestLog.input_tokens, String).ilike(search_pattern),
136
+ cast(RequestLog.output_tokens, String).ilike(search_pattern),
137
+ cast(RequestLog.cached_input_tokens, String).ilike(search_pattern),
138
+ cast(RequestLog.reasoning_tokens, String).ilike(search_pattern),
139
+ cast(RequestLog.latency_ms, String).ilike(search_pattern),
140
+ )
141
+ )
142
+
143
+ stmt = (
144
+ select(RequestLog)
145
+ .outerjoin(Account, Account.id == RequestLog.account_id)
146
+ .order_by(RequestLog.requested_at.desc())
147
+ )
88
148
  if conditions:
89
149
  stmt = stmt.where(and_(*conditions))
150
+ if offset:
151
+ stmt = stmt.offset(offset)
90
152
  if limit:
91
153
  stmt = stmt.limit(limit)
92
154
  result = await self._session.execute(stmt)
93
155
  return list(result.scalars().all())
94
156
 
157
+ async def list_filter_options(
158
+ self,
159
+ since: datetime | None = None,
160
+ until: datetime | None = None,
161
+ include_success: bool = True,
162
+ include_error_other: bool = True,
163
+ error_codes_in: list[str] | None = None,
164
+ error_codes_excluding: list[str] | None = None,
165
+ ) -> tuple[list[str], list[tuple[str, str | None]]]:
166
+ conditions = []
167
+ if since is not None:
168
+ conditions.append(RequestLog.requested_at >= since)
169
+ if until is not None:
170
+ conditions.append(RequestLog.requested_at <= until)
171
+ status_conditions = []
172
+ if include_success:
173
+ status_conditions.append(RequestLog.status == "success")
174
+ if error_codes_in:
175
+ status_conditions.append(and_(RequestLog.status == "error", RequestLog.error_code.in_(error_codes_in)))
176
+ if include_error_other:
177
+ error_clause = [RequestLog.status == "error"]
178
+ if error_codes_excluding:
179
+ error_clause.append(
180
+ or_(
181
+ RequestLog.error_code.is_(None),
182
+ ~RequestLog.error_code.in_(error_codes_excluding),
183
+ )
184
+ )
185
+ status_conditions.append(and_(*error_clause))
186
+ if status_conditions:
187
+ conditions.append(or_(*status_conditions))
188
+
189
+ account_stmt = select(RequestLog.account_id).distinct().order_by(RequestLog.account_id.asc())
190
+ model_stmt = (
191
+ select(RequestLog.model, RequestLog.reasoning_effort)
192
+ .distinct()
193
+ .order_by(RequestLog.model.asc(), RequestLog.reasoning_effort.asc())
194
+ )
195
+ if conditions:
196
+ clause = and_(*conditions)
197
+ account_stmt = account_stmt.where(clause)
198
+ model_stmt = model_stmt.where(clause)
199
+
200
+ account_rows = await self._session.execute(account_stmt)
201
+ model_rows = await self._session.execute(model_stmt)
202
+
203
+ account_ids = [row[0] for row in account_rows.all() if row[0]]
204
+ model_options = [(row[0], row[1]) for row in model_rows.all() if row[0]]
205
+ return account_ids, model_options
206
+
95
207
 
96
208
  async def _safe_rollback(session: AsyncSession) -> None:
97
209
  if not session.in_transaction():
@@ -1,7 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from datetime import datetime
4
- from typing import List
5
4
 
6
5
  from pydantic import Field
7
6
 
@@ -24,4 +23,14 @@ class RequestLogEntry(DashboardModel):
24
23
 
25
24
 
26
25
  class RequestLogsResponse(DashboardModel):
27
- requests: List[RequestLogEntry] = Field(default_factory=list)
26
+ requests: list[RequestLogEntry] = Field(default_factory=list)
27
+
28
+
29
+ class RequestLogModelOption(DashboardModel):
30
+ model: str
31
+ reasoning_effort: str | None = None
32
+
33
+
34
+ class RequestLogFilterOptionsResponse(DashboardModel):
35
+ account_ids: list[str] = Field(default_factory=list)
36
+ model_options: list[RequestLogModelOption] = Field(default_factory=list)
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from dataclasses import dataclass
3
4
  from datetime import datetime
4
5
  from typing import cast
5
6
 
@@ -17,6 +18,26 @@ RATE_LIMIT_CODES = {"rate_limit_exceeded", "usage_limit_reached"}
17
18
  QUOTA_CODES = {"insufficient_quota", "usage_not_included", "quota_exceeded"}
18
19
 
19
20
 
21
+ @dataclass(frozen=True, slots=True)
22
+ class RequestLogModelOption:
23
+ model: str
24
+ reasoning_effort: str | None
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class RequestLogStatusFilter:
29
+ include_success: bool
30
+ include_error_other: bool
31
+ error_codes_in: list[str] | None
32
+ error_codes_excluding: list[str] | None
33
+
34
+
35
+ @dataclass(frozen=True, slots=True)
36
+ class RequestLogFilterOptions:
37
+ account_ids: list[str]
38
+ model_options: list[RequestLogModelOption]
39
+
40
+
20
41
  class RequestLogsService:
21
42
  def __init__(self, repo: RequestLogsRepository) -> None:
22
43
  self._repo = repo
@@ -24,38 +45,94 @@ class RequestLogsService:
24
45
  async def list_recent(
25
46
  self,
26
47
  limit: int = 50,
48
+ offset: int = 0,
49
+ search: str | None = None,
27
50
  since: datetime | None = None,
28
51
  until: datetime | None = None,
29
- account_id: str | None = None,
30
- model: str | None = None,
31
- status: str | None = None,
52
+ account_ids: list[str] | None = None,
53
+ model_options: list[RequestLogModelOption] | None = None,
54
+ models: list[str] | None = None,
55
+ reasoning_efforts: list[str] | None = None,
56
+ status: list[str] | None = None,
32
57
  ) -> list[RequestLogEntry]:
33
- status_filter, error_codes = _map_status_filter(status)
58
+ status_filter = _map_status_filter(status)
34
59
  logs = await self._repo.list_recent(
35
60
  limit=limit,
61
+ offset=offset,
62
+ search=search,
36
63
  since=since,
37
64
  until=until,
38
- account_id=account_id,
39
- model=model,
40
- status=status_filter,
41
- error_codes=error_codes,
65
+ account_ids=account_ids,
66
+ model_options=(
67
+ [(option.model, option.reasoning_effort) for option in model_options] if model_options else None
68
+ ),
69
+ models=models,
70
+ reasoning_efforts=reasoning_efforts,
71
+ include_success=status_filter.include_success,
72
+ include_error_other=status_filter.include_error_other,
73
+ error_codes_in=status_filter.error_codes_in,
74
+ error_codes_excluding=status_filter.error_codes_excluding,
42
75
  )
43
76
  return [_to_entry(log) for log in logs]
44
77
 
78
+ async def list_filter_options(
79
+ self,
80
+ since: datetime | None = None,
81
+ until: datetime | None = None,
82
+ status: list[str] | None = None,
83
+ ) -> RequestLogFilterOptions:
84
+ status_filter = _map_status_filter(status)
85
+ account_ids, model_options = await self._repo.list_filter_options(
86
+ since=since,
87
+ until=until,
88
+ include_success=status_filter.include_success,
89
+ include_error_other=status_filter.include_error_other,
90
+ error_codes_in=status_filter.error_codes_in,
91
+ error_codes_excluding=status_filter.error_codes_excluding,
92
+ )
93
+ return RequestLogFilterOptions(
94
+ account_ids=account_ids,
95
+ model_options=[
96
+ RequestLogModelOption(model=model, reasoning_effort=reasoning_effort)
97
+ for model, reasoning_effort in model_options
98
+ ],
99
+ )
100
+
45
101
 
46
- def _map_status_filter(status: str | None) -> tuple[str | None, list[str] | None]:
102
+ def _map_status_filter(status: list[str] | None) -> RequestLogStatusFilter:
47
103
  if not status:
48
- return None, None
49
- normalized = status.lower()
50
- if normalized == "ok":
51
- return "success", None
52
- if normalized == "rate_limit":
53
- return "error", sorted(RATE_LIMIT_CODES)
54
- if normalized == "quota":
55
- return "error", sorted(QUOTA_CODES)
56
- if normalized == "error":
57
- return "error", None
58
- return status, None
104
+ return RequestLogStatusFilter(
105
+ include_success=True,
106
+ include_error_other=True,
107
+ error_codes_in=None,
108
+ error_codes_excluding=None,
109
+ )
110
+ normalized = {value.lower() for value in status if value}
111
+ if not normalized or "all" in normalized:
112
+ return RequestLogStatusFilter(
113
+ include_success=True,
114
+ include_error_other=True,
115
+ error_codes_in=None,
116
+ error_codes_excluding=None,
117
+ )
118
+
119
+ include_success = "ok" in normalized
120
+ include_rate_limit = "rate_limit" in normalized
121
+ include_quota = "quota" in normalized
122
+ include_error_other = "error" in normalized
123
+
124
+ error_codes_in: set[str] = set()
125
+ if include_rate_limit:
126
+ error_codes_in |= RATE_LIMIT_CODES
127
+ if include_quota:
128
+ error_codes_in |= QUOTA_CODES
129
+
130
+ return RequestLogStatusFilter(
131
+ include_success=include_success,
132
+ include_error_other=include_error_other,
133
+ error_codes_in=sorted(error_codes_in) if error_codes_in else None,
134
+ error_codes_excluding=sorted(RATE_LIMIT_CODES | QUOTA_CODES) if include_error_other else None,
135
+ )
59
136
 
60
137
 
61
138
  def _log_status(log: RequestLog) -> str:
@@ -2,8 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import logging
4
4
  import math
5
- from collections import Counter
6
- from datetime import datetime
5
+ from datetime import datetime, timezone
7
6
  from typing import Mapping, Protocol
8
7
 
9
8
  from app.core.auth.refresh import RefreshError
@@ -14,8 +13,7 @@ from app.core.usage.models import UsagePayload
14
13
  from app.core.utils.request_id import get_request_id
15
14
  from app.core.utils.time import utcnow
16
15
  from app.db.models import Account, AccountStatus, UsageHistory
17
- from app.modules.accounts.auth_manager import AuthManager
18
- from app.modules.accounts.repository import AccountsRepository
16
+ from app.modules.accounts.auth_manager import AccountsRepositoryPort, AuthManager
19
17
 
20
18
  logger = logging.getLogger(__name__)
21
19
 
@@ -41,7 +39,7 @@ class UsageUpdater:
41
39
  def __init__(
42
40
  self,
43
41
  usage_repo: UsageRepositoryPort,
44
- accounts_repo: AccountsRepository | None = None,
42
+ accounts_repo: AccountsRepositoryPort | None = None,
45
43
  ) -> None:
46
44
  self._usage_repo = usage_repo
47
45
  self._encryptor = TokenEncryptor()
@@ -56,7 +54,6 @@ class UsageUpdater:
56
54
  if not settings.usage_refresh_enabled:
57
55
  return
58
56
 
59
- shared_chatgpt_account_ids = _shared_chatgpt_account_ids(accounts)
60
57
  now = utcnow()
61
58
  interval = settings.usage_refresh_interval_seconds
62
59
  for account in accounts:
@@ -65,16 +62,11 @@ class UsageUpdater:
65
62
  latest = latest_usage.get(account.id)
66
63
  if latest and (now - latest.recorded_at).total_seconds() < interval:
67
64
  continue
68
- usage_account_id = (
69
- None
70
- if account.chatgpt_account_id and account.chatgpt_account_id in shared_chatgpt_account_ids
71
- else account.chatgpt_account_id
72
- )
73
65
  # NOTE: AsyncSession is not safe for concurrent use. Run sequentially
74
66
  # within the request-scoped session to avoid PK collisions and
75
67
  # flush-time warnings (SAWarning: Session.add during flush).
76
68
  try:
77
- await self._refresh_account(account, usage_account_id=usage_account_id)
69
+ await self._refresh_account(account, usage_account_id=account.chatgpt_account_id)
78
70
  except Exception as exc:
79
71
  logger.warning(
80
72
  "Usage refresh failed account_id=%s request_id=%s error=%s",
@@ -88,12 +80,16 @@ class UsageUpdater:
88
80
 
89
81
  async def _refresh_account(self, account: Account, *, usage_account_id: str | None) -> None:
90
82
  access_token = self._encryptor.decrypt(account.access_token_encrypted)
83
+ payload: UsagePayload | None = None
91
84
  try:
92
85
  payload = await fetch_usage(
93
86
  access_token=access_token,
94
87
  account_id=usage_account_id,
95
88
  )
96
89
  except UsageFetchError as exc:
90
+ if _should_deactivate_for_usage_error(exc.status_code):
91
+ await self._deactivate_for_client_error(account, exc)
92
+ return
97
93
  if exc.status_code != 401 or not self._auth_manager:
98
94
  return
99
95
  try:
@@ -106,25 +102,32 @@ class UsageUpdater:
106
102
  access_token=access_token,
107
103
  account_id=usage_account_id,
108
104
  )
109
- except UsageFetchError:
105
+ except UsageFetchError as retry_exc:
106
+ if _should_deactivate_for_usage_error(retry_exc.status_code):
107
+ await self._deactivate_for_client_error(account, retry_exc)
110
108
  return
111
109
 
110
+ if payload is None:
111
+ return
112
+
112
113
  rate_limit = payload.rate_limit
113
- primary = rate_limit.primary_window if rate_limit else None
114
+ if rate_limit is None:
115
+ return
116
+
117
+ primary = rate_limit.primary_window
118
+ secondary = rate_limit.secondary_window
114
119
  credits_has, credits_unlimited, credits_balance = _credits_snapshot(payload)
115
- primary_window_minutes = _window_minutes(primary.limit_window_seconds) if primary else None
116
- secondary = rate_limit.secondary_window if rate_limit else None
117
- secondary_window_minutes = _window_minutes(secondary.limit_window_seconds) if secondary else None
120
+ now_epoch = _now_epoch()
118
121
 
119
122
  if primary and primary.used_percent is not None:
120
123
  await self._usage_repo.add_entry(
121
124
  account_id=account.id,
122
- used_percent=primary.used_percent,
125
+ used_percent=float(primary.used_percent),
123
126
  input_tokens=None,
124
127
  output_tokens=None,
125
128
  window="primary",
126
- reset_at=primary.reset_at,
127
- window_minutes=primary_window_minutes,
129
+ reset_at=_reset_at(primary.reset_at, primary.reset_after_seconds, now_epoch),
130
+ window_minutes=_window_minutes(primary.limit_window_seconds),
128
131
  credits_has=credits_has,
129
132
  credits_unlimited=credits_unlimited,
130
133
  credits_balance=credits_balance,
@@ -133,14 +136,29 @@ class UsageUpdater:
133
136
  if secondary and secondary.used_percent is not None:
134
137
  await self._usage_repo.add_entry(
135
138
  account_id=account.id,
136
- used_percent=secondary.used_percent,
139
+ used_percent=float(secondary.used_percent),
137
140
  input_tokens=None,
138
141
  output_tokens=None,
139
142
  window="secondary",
140
- reset_at=secondary.reset_at,
141
- window_minutes=secondary_window_minutes,
143
+ reset_at=_reset_at(secondary.reset_at, secondary.reset_after_seconds, now_epoch),
144
+ window_minutes=_window_minutes(secondary.limit_window_seconds),
142
145
  )
143
146
 
147
+ async def _deactivate_for_client_error(self, account: Account, exc: UsageFetchError) -> None:
148
+ if not self._auth_manager:
149
+ return
150
+ reason = f"Usage API error: HTTP {exc.status_code} - {exc.message}"
151
+ logger.warning(
152
+ "Deactivating account due to client error account_id=%s status=%s message=%s request_id=%s",
153
+ account.id,
154
+ exc.status_code,
155
+ exc.message,
156
+ get_request_id(),
157
+ )
158
+ await self._auth_manager._repo.update_status(account.id, AccountStatus.DEACTIVATED, reason)
159
+ account.status = AccountStatus.DEACTIVATED
160
+ account.deactivation_reason = reason
161
+
144
162
 
145
163
  def _credits_snapshot(payload: UsagePayload) -> tuple[bool | None, bool | None, float | None]:
146
164
  credits = payload.credits
@@ -171,6 +189,20 @@ def _window_minutes(limit_seconds: int | None) -> int | None:
171
189
  return max(1, math.ceil(limit_seconds / 60))
172
190
 
173
191
 
174
- def _shared_chatgpt_account_ids(accounts: list[Account]) -> set[str]:
175
- counts = Counter(account.chatgpt_account_id for account in accounts if account.chatgpt_account_id)
176
- return {account_id for account_id, count in counts.items() if count > 1}
192
+ def _now_epoch() -> int:
193
+ return int(utcnow().replace(tzinfo=timezone.utc).timestamp())
194
+
195
+
196
+ def _reset_at(reset_at: int | None, reset_after_seconds: int | None, now_epoch: int) -> int | None:
197
+ if reset_at is not None:
198
+ return int(reset_at)
199
+ if reset_after_seconds is None:
200
+ return None
201
+ return now_epoch + max(0, int(reset_after_seconds))
202
+
203
+
204
+ _DEACTIVATING_USAGE_STATUS_CODES = {402, 403, 404}
205
+
206
+
207
+ def _should_deactivate_for_usage_error(status_code: int) -> bool:
208
+ return status_code in _DEACTIVATING_USAGE_STATUS_CODES