codex-lb 0.3.1__py3-none-any.whl → 0.5.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/core/clients/proxy.py +33 -3
- app/core/config/settings.py +9 -8
- app/core/handlers/__init__.py +3 -0
- app/core/handlers/exceptions.py +39 -0
- app/core/middleware/__init__.py +9 -0
- app/core/middleware/api_errors.py +33 -0
- app/core/middleware/request_decompression.py +101 -0
- app/core/middleware/request_id.py +27 -0
- app/core/openai/chat_requests.py +172 -0
- app/core/openai/chat_responses.py +534 -0
- app/core/openai/message_coercion.py +60 -0
- app/core/openai/models_catalog.py +72 -0
- app/core/openai/requests.py +23 -5
- app/core/openai/v1_requests.py +92 -0
- app/db/models.py +3 -3
- app/db/session.py +25 -8
- app/dependencies.py +43 -16
- app/main.py +13 -67
- app/modules/accounts/repository.py +25 -10
- app/modules/proxy/api.py +94 -0
- app/modules/proxy/load_balancer.py +75 -58
- app/modules/proxy/repo_bundle.py +23 -0
- app/modules/proxy/service.py +127 -102
- app/modules/request_logs/api.py +61 -7
- app/modules/request_logs/repository.py +131 -16
- app/modules/request_logs/schemas.py +11 -2
- app/modules/request_logs/service.py +97 -20
- app/modules/usage/service.py +65 -4
- app/modules/usage/updater.py +58 -26
- app/static/index.css +378 -1
- app/static/index.html +183 -8
- app/static/index.js +308 -13
- {codex_lb-0.3.1.dist-info → codex_lb-0.5.0.dist-info}/METADATA +42 -3
- {codex_lb-0.3.1.dist-info → codex_lb-0.5.0.dist-info}/RECORD +37 -25
- {codex_lb-0.3.1.dist-info → codex_lb-0.5.0.dist-info}/WHEEL +0 -0
- {codex_lb-0.3.1.dist-info → codex_lb-0.5.0.dist-info}/entry_points.txt +0 -0
- {codex_lb-0.3.1.dist-info → codex_lb-0.5.0.dist-info}/licenses/LICENSE +0 -0
app/modules/request_logs/api.py
CHANGED
|
@@ -5,27 +5,81 @@ from datetime import datetime
|
|
|
5
5
|
from fastapi import APIRouter, Depends, Query
|
|
6
6
|
|
|
7
7
|
from app.dependencies import RequestLogsContext, get_request_logs_context
|
|
8
|
-
from app.modules.request_logs.schemas import
|
|
8
|
+
from app.modules.request_logs.schemas import (
|
|
9
|
+
RequestLogFilterOptionsResponse,
|
|
10
|
+
RequestLogModelOption,
|
|
11
|
+
RequestLogsResponse,
|
|
12
|
+
)
|
|
13
|
+
from app.modules.request_logs.service import RequestLogModelOption as ServiceRequestLogModelOption
|
|
9
14
|
|
|
10
15
|
router = APIRouter(prefix="/api/request-logs", tags=["dashboard"])
|
|
11
16
|
|
|
17
|
+
_MODEL_OPTION_DELIMITER = ":::"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _parse_model_option(value: str) -> ServiceRequestLogModelOption | None:
|
|
21
|
+
raw = (value or "").strip()
|
|
22
|
+
if not raw:
|
|
23
|
+
return None
|
|
24
|
+
if _MODEL_OPTION_DELIMITER not in raw:
|
|
25
|
+
return ServiceRequestLogModelOption(model=raw, reasoning_effort=None)
|
|
26
|
+
model, effort = raw.split(_MODEL_OPTION_DELIMITER, 1)
|
|
27
|
+
model = model.strip()
|
|
28
|
+
effort = effort.strip()
|
|
29
|
+
if not model:
|
|
30
|
+
return None
|
|
31
|
+
return ServiceRequestLogModelOption(model=model, reasoning_effort=effort or None)
|
|
32
|
+
|
|
12
33
|
|
|
13
34
|
@router.get("", response_model=RequestLogsResponse)
|
|
14
35
|
async def list_request_logs(
|
|
15
|
-
limit: int = Query(50, ge=1, le=
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
36
|
+
limit: int = Query(50, ge=1, le=1000),
|
|
37
|
+
offset: int = Query(0, ge=0),
|
|
38
|
+
search: str | None = Query(default=None),
|
|
39
|
+
account_id: list[str] | None = Query(default=None, alias="accountId"),
|
|
40
|
+
status: list[str] | None = Query(default=None),
|
|
41
|
+
model: list[str] | None = Query(default=None),
|
|
42
|
+
reasoning_effort: list[str] | None = Query(default=None, alias="reasoningEffort"),
|
|
43
|
+
model_option: list[str] | None = Query(default=None, alias="modelOption"),
|
|
19
44
|
since: datetime | None = Query(default=None),
|
|
20
45
|
until: datetime | None = Query(default=None),
|
|
21
46
|
context: RequestLogsContext = Depends(get_request_logs_context),
|
|
22
47
|
) -> RequestLogsResponse:
|
|
48
|
+
parsed_options: list[ServiceRequestLogModelOption] | None = None
|
|
49
|
+
if model_option:
|
|
50
|
+
parsed = [_parse_model_option(value) for value in model_option]
|
|
51
|
+
parsed_options = [value for value in parsed if value is not None] or None
|
|
23
52
|
logs = await context.service.list_recent(
|
|
24
53
|
limit=limit,
|
|
54
|
+
offset=offset,
|
|
55
|
+
search=search,
|
|
25
56
|
since=since,
|
|
26
57
|
until=until,
|
|
27
|
-
|
|
28
|
-
|
|
58
|
+
account_ids=account_id,
|
|
59
|
+
model_options=parsed_options,
|
|
60
|
+
models=model,
|
|
61
|
+
reasoning_efforts=reasoning_effort,
|
|
29
62
|
status=status,
|
|
30
63
|
)
|
|
31
64
|
return RequestLogsResponse(requests=logs)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@router.get("/options", response_model=RequestLogFilterOptionsResponse)
|
|
68
|
+
async def list_request_log_filter_options(
|
|
69
|
+
status: list[str] | None = Query(default=None),
|
|
70
|
+
since: datetime | None = Query(default=None),
|
|
71
|
+
until: datetime | None = Query(default=None),
|
|
72
|
+
context: RequestLogsContext = Depends(get_request_logs_context),
|
|
73
|
+
) -> RequestLogFilterOptionsResponse:
|
|
74
|
+
options = await context.service.list_filter_options(
|
|
75
|
+
status=status,
|
|
76
|
+
since=since,
|
|
77
|
+
until=until,
|
|
78
|
+
)
|
|
79
|
+
return RequestLogFilterOptionsResponse(
|
|
80
|
+
account_ids=options.account_ids,
|
|
81
|
+
model_options=[
|
|
82
|
+
RequestLogModelOption(model=option.model, reasoning_effort=option.reasoning_effort)
|
|
83
|
+
for option in options.model_options
|
|
84
|
+
],
|
|
85
|
+
)
|
|
@@ -3,12 +3,13 @@ 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
|
+
from sqlalchemy import exc as sa_exc
|
|
7
8
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
8
9
|
|
|
9
10
|
from app.core.utils.request_id import ensure_request_id
|
|
10
11
|
from app.core.utils.time import utcnow
|
|
11
|
-
from app.db.models import RequestLog
|
|
12
|
+
from app.db.models import Account, RequestLog
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
class RequestLogsRepository:
|
|
@@ -56,6 +57,8 @@ class RequestLogsRepository:
|
|
|
56
57
|
await self._session.commit()
|
|
57
58
|
await self._session.refresh(log)
|
|
58
59
|
return log
|
|
60
|
+
except sa_exc.ResourceClosedError:
|
|
61
|
+
return log
|
|
59
62
|
except BaseException:
|
|
60
63
|
await _safe_rollback(self._session)
|
|
61
64
|
raise
|
|
@@ -63,35 +66,147 @@ class RequestLogsRepository:
|
|
|
63
66
|
async def list_recent(
|
|
64
67
|
self,
|
|
65
68
|
limit: int = 50,
|
|
69
|
+
offset: int = 0,
|
|
70
|
+
search: str | None = None,
|
|
66
71
|
since: datetime | None = None,
|
|
67
72
|
until: datetime | None = None,
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
73
|
+
account_ids: list[str] | None = None,
|
|
74
|
+
model_options: list[tuple[str, str | None]] | None = None,
|
|
75
|
+
models: list[str] | None = None,
|
|
76
|
+
reasoning_efforts: list[str] | None = None,
|
|
77
|
+
include_success: bool = True,
|
|
78
|
+
include_error_other: bool = True,
|
|
79
|
+
error_codes_in: list[str] | None = None,
|
|
80
|
+
error_codes_excluding: list[str] | None = None,
|
|
72
81
|
) -> list[RequestLog]:
|
|
73
82
|
conditions = []
|
|
74
83
|
if since is not None:
|
|
75
84
|
conditions.append(RequestLog.requested_at >= since)
|
|
76
85
|
if until is not None:
|
|
77
86
|
conditions.append(RequestLog.requested_at <= until)
|
|
78
|
-
if
|
|
79
|
-
conditions.append(RequestLog.account_id
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
87
|
+
if account_ids:
|
|
88
|
+
conditions.append(RequestLog.account_id.in_(account_ids))
|
|
89
|
+
|
|
90
|
+
if model_options:
|
|
91
|
+
pair_conditions = []
|
|
92
|
+
for model, effort in model_options:
|
|
93
|
+
base = (model or "").strip()
|
|
94
|
+
if not base:
|
|
95
|
+
continue
|
|
96
|
+
if effort is None:
|
|
97
|
+
pair_conditions.append(and_(RequestLog.model == base, RequestLog.reasoning_effort.is_(None)))
|
|
98
|
+
else:
|
|
99
|
+
pair_conditions.append(and_(RequestLog.model == base, RequestLog.reasoning_effort == effort))
|
|
100
|
+
if pair_conditions:
|
|
101
|
+
conditions.append(or_(*pair_conditions))
|
|
102
|
+
else:
|
|
103
|
+
if models:
|
|
104
|
+
conditions.append(RequestLog.model.in_(models))
|
|
105
|
+
if reasoning_efforts:
|
|
106
|
+
conditions.append(RequestLog.reasoning_effort.in_(reasoning_efforts))
|
|
107
|
+
|
|
108
|
+
status_conditions = []
|
|
109
|
+
if include_success:
|
|
110
|
+
status_conditions.append(RequestLog.status == "success")
|
|
111
|
+
if error_codes_in:
|
|
112
|
+
status_conditions.append(and_(RequestLog.status == "error", RequestLog.error_code.in_(error_codes_in)))
|
|
113
|
+
if include_error_other:
|
|
114
|
+
error_clause = [RequestLog.status == "error"]
|
|
115
|
+
if error_codes_excluding:
|
|
116
|
+
error_clause.append(
|
|
117
|
+
or_(
|
|
118
|
+
RequestLog.error_code.is_(None),
|
|
119
|
+
~RequestLog.error_code.in_(error_codes_excluding),
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
status_conditions.append(and_(*error_clause))
|
|
123
|
+
if status_conditions:
|
|
124
|
+
conditions.append(or_(*status_conditions))
|
|
125
|
+
if search:
|
|
126
|
+
search_pattern = f"%{search}%"
|
|
127
|
+
conditions.append(
|
|
128
|
+
or_(
|
|
129
|
+
RequestLog.account_id.ilike(search_pattern),
|
|
130
|
+
Account.email.ilike(search_pattern),
|
|
131
|
+
RequestLog.request_id.ilike(search_pattern),
|
|
132
|
+
RequestLog.model.ilike(search_pattern),
|
|
133
|
+
RequestLog.reasoning_effort.ilike(search_pattern),
|
|
134
|
+
RequestLog.status.ilike(search_pattern),
|
|
135
|
+
RequestLog.error_code.ilike(search_pattern),
|
|
136
|
+
RequestLog.error_message.ilike(search_pattern),
|
|
137
|
+
cast(RequestLog.requested_at, String).ilike(search_pattern),
|
|
138
|
+
cast(RequestLog.input_tokens, String).ilike(search_pattern),
|
|
139
|
+
cast(RequestLog.output_tokens, String).ilike(search_pattern),
|
|
140
|
+
cast(RequestLog.cached_input_tokens, String).ilike(search_pattern),
|
|
141
|
+
cast(RequestLog.reasoning_tokens, String).ilike(search_pattern),
|
|
142
|
+
cast(RequestLog.latency_ms, String).ilike(search_pattern),
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
stmt = (
|
|
147
|
+
select(RequestLog)
|
|
148
|
+
.outerjoin(Account, Account.id == RequestLog.account_id)
|
|
149
|
+
.order_by(RequestLog.requested_at.desc())
|
|
150
|
+
)
|
|
88
151
|
if conditions:
|
|
89
152
|
stmt = stmt.where(and_(*conditions))
|
|
153
|
+
if offset:
|
|
154
|
+
stmt = stmt.offset(offset)
|
|
90
155
|
if limit:
|
|
91
156
|
stmt = stmt.limit(limit)
|
|
92
157
|
result = await self._session.execute(stmt)
|
|
93
158
|
return list(result.scalars().all())
|
|
94
159
|
|
|
160
|
+
async def list_filter_options(
|
|
161
|
+
self,
|
|
162
|
+
since: datetime | None = None,
|
|
163
|
+
until: datetime | None = None,
|
|
164
|
+
include_success: bool = True,
|
|
165
|
+
include_error_other: bool = True,
|
|
166
|
+
error_codes_in: list[str] | None = None,
|
|
167
|
+
error_codes_excluding: list[str] | None = None,
|
|
168
|
+
) -> tuple[list[str], list[tuple[str, str | None]]]:
|
|
169
|
+
conditions = []
|
|
170
|
+
if since is not None:
|
|
171
|
+
conditions.append(RequestLog.requested_at >= since)
|
|
172
|
+
if until is not None:
|
|
173
|
+
conditions.append(RequestLog.requested_at <= until)
|
|
174
|
+
status_conditions = []
|
|
175
|
+
if include_success:
|
|
176
|
+
status_conditions.append(RequestLog.status == "success")
|
|
177
|
+
if error_codes_in:
|
|
178
|
+
status_conditions.append(and_(RequestLog.status == "error", RequestLog.error_code.in_(error_codes_in)))
|
|
179
|
+
if include_error_other:
|
|
180
|
+
error_clause = [RequestLog.status == "error"]
|
|
181
|
+
if error_codes_excluding:
|
|
182
|
+
error_clause.append(
|
|
183
|
+
or_(
|
|
184
|
+
RequestLog.error_code.is_(None),
|
|
185
|
+
~RequestLog.error_code.in_(error_codes_excluding),
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
status_conditions.append(and_(*error_clause))
|
|
189
|
+
if status_conditions:
|
|
190
|
+
conditions.append(or_(*status_conditions))
|
|
191
|
+
|
|
192
|
+
account_stmt = select(RequestLog.account_id).distinct().order_by(RequestLog.account_id.asc())
|
|
193
|
+
model_stmt = (
|
|
194
|
+
select(RequestLog.model, RequestLog.reasoning_effort)
|
|
195
|
+
.distinct()
|
|
196
|
+
.order_by(RequestLog.model.asc(), RequestLog.reasoning_effort.asc())
|
|
197
|
+
)
|
|
198
|
+
if conditions:
|
|
199
|
+
clause = and_(*conditions)
|
|
200
|
+
account_stmt = account_stmt.where(clause)
|
|
201
|
+
model_stmt = model_stmt.where(clause)
|
|
202
|
+
|
|
203
|
+
account_rows = await self._session.execute(account_stmt)
|
|
204
|
+
model_rows = await self._session.execute(model_stmt)
|
|
205
|
+
|
|
206
|
+
account_ids = [row[0] for row in account_rows.all() if row[0]]
|
|
207
|
+
model_options = [(row[0], row[1]) for row in model_rows.all() if row[0]]
|
|
208
|
+
return account_ids, model_options
|
|
209
|
+
|
|
95
210
|
|
|
96
211
|
async def _safe_rollback(session: AsyncSession) -> None:
|
|
97
212
|
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:
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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) ->
|
|
102
|
+
def _map_status_filter(status: list[str] | None) -> RequestLogStatusFilter:
|
|
47
103
|
if not status:
|
|
48
|
-
return
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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:
|
app/modules/usage/service.py
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
4
|
+
import weakref
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
3
7
|
from datetime import timedelta
|
|
4
|
-
from typing import cast
|
|
8
|
+
from typing import AsyncContextManager, ClassVar, cast
|
|
5
9
|
|
|
6
10
|
from app.core import usage as usage_core
|
|
7
11
|
from app.core.usage.logs import (
|
|
@@ -37,17 +41,30 @@ from app.modules.usage.schemas import (
|
|
|
37
41
|
from app.modules.usage.updater import UsageUpdater
|
|
38
42
|
|
|
39
43
|
|
|
44
|
+
@dataclass(slots=True)
|
|
45
|
+
class _RefreshState:
|
|
46
|
+
lock: asyncio.Lock
|
|
47
|
+
task: asyncio.Task[None] | None = None
|
|
48
|
+
|
|
49
|
+
|
|
40
50
|
class UsageService:
|
|
51
|
+
_refresh_states: ClassVar[weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, _RefreshState]] = (
|
|
52
|
+
weakref.WeakKeyDictionary()
|
|
53
|
+
)
|
|
54
|
+
|
|
41
55
|
def __init__(
|
|
42
56
|
self,
|
|
43
57
|
usage_repo: UsageRepository,
|
|
44
58
|
logs_repo: RequestLogsRepository,
|
|
45
59
|
accounts_repo: AccountsRepository,
|
|
60
|
+
refresh_repo_factory: Callable[[], AsyncContextManager[tuple[UsageRepository, AccountsRepository]]]
|
|
61
|
+
| None = None,
|
|
46
62
|
) -> None:
|
|
47
63
|
self._usage_repo = usage_repo
|
|
48
64
|
self._logs_repo = logs_repo
|
|
49
65
|
self._accounts_repo = accounts_repo
|
|
50
66
|
self._usage_updater = UsageUpdater(usage_repo, accounts_repo)
|
|
67
|
+
self._refresh_repo_factory = refresh_repo_factory
|
|
51
68
|
|
|
52
69
|
async def get_usage_summary(self) -> UsageSummaryResponse:
|
|
53
70
|
await self._refresh_usage()
|
|
@@ -114,9 +131,53 @@ class UsageService:
|
|
|
114
131
|
)
|
|
115
132
|
|
|
116
133
|
async def _refresh_usage(self) -> None:
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
134
|
+
state = self._refresh_state()
|
|
135
|
+
task = state.task
|
|
136
|
+
if task and not task.done():
|
|
137
|
+
await asyncio.shield(task)
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
created = False
|
|
141
|
+
async with state.lock:
|
|
142
|
+
task = state.task
|
|
143
|
+
if not task or task.done():
|
|
144
|
+
task = asyncio.create_task(self._refresh_usage_once())
|
|
145
|
+
state.task = task
|
|
146
|
+
created = True
|
|
147
|
+
|
|
148
|
+
if task is None:
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
if created:
|
|
153
|
+
await asyncio.shield(task)
|
|
154
|
+
else:
|
|
155
|
+
await asyncio.shield(task)
|
|
156
|
+
finally:
|
|
157
|
+
if task.done() and state.task is task:
|
|
158
|
+
state.task = None
|
|
159
|
+
|
|
160
|
+
async def _refresh_usage_once(self) -> None:
|
|
161
|
+
if self._refresh_repo_factory is None:
|
|
162
|
+
accounts = await self._accounts_repo.list_accounts()
|
|
163
|
+
latest_usage = await self._usage_repo.latest_by_account(window="primary")
|
|
164
|
+
await self._usage_updater.refresh_accounts(accounts, latest_usage)
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
async with self._refresh_repo_factory() as (usage_repo, accounts_repo):
|
|
168
|
+
latest_usage = await usage_repo.latest_by_account(window="primary")
|
|
169
|
+
accounts = await accounts_repo.list_accounts()
|
|
170
|
+
updater = UsageUpdater(usage_repo, accounts_repo)
|
|
171
|
+
await updater.refresh_accounts(accounts, latest_usage)
|
|
172
|
+
|
|
173
|
+
@classmethod
|
|
174
|
+
def _refresh_state(cls) -> _RefreshState:
|
|
175
|
+
loop = asyncio.get_running_loop()
|
|
176
|
+
state = cls._refresh_states.get(loop)
|
|
177
|
+
if state is None:
|
|
178
|
+
state = _RefreshState(lock=asyncio.Lock())
|
|
179
|
+
cls._refresh_states[loop] = state
|
|
180
|
+
return state
|
|
120
181
|
|
|
121
182
|
async def _latest_usage_rows(self, window: str) -> list[UsageWindowRow]:
|
|
122
183
|
latest = await self._usage_repo.latest_by_account(window=window)
|