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
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import asyncio
|
|
4
3
|
from datetime import datetime
|
|
5
4
|
|
|
5
|
+
import anyio
|
|
6
6
|
from sqlalchemy import and_, select
|
|
7
7
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
8
8
|
|
|
@@ -33,6 +33,7 @@ class RequestLogsRepository:
|
|
|
33
33
|
requested_at: datetime | None = None,
|
|
34
34
|
cached_input_tokens: int | None = None,
|
|
35
35
|
reasoning_tokens: int | None = None,
|
|
36
|
+
reasoning_effort: str | None = None,
|
|
36
37
|
) -> RequestLog:
|
|
37
38
|
resolved_request_id = ensure_request_id(request_id)
|
|
38
39
|
log = RequestLog(
|
|
@@ -43,6 +44,7 @@ class RequestLogsRepository:
|
|
|
43
44
|
output_tokens=output_tokens,
|
|
44
45
|
cached_input_tokens=cached_input_tokens,
|
|
45
46
|
reasoning_tokens=reasoning_tokens,
|
|
47
|
+
reasoning_effort=reasoning_effort,
|
|
46
48
|
latency_ms=latency_ms,
|
|
47
49
|
status=status,
|
|
48
50
|
error_code=error_code,
|
|
@@ -95,6 +97,7 @@ async def _safe_rollback(session: AsyncSession) -> None:
|
|
|
95
97
|
if not session.in_transaction():
|
|
96
98
|
return
|
|
97
99
|
try:
|
|
98
|
-
|
|
99
|
-
|
|
100
|
+
with anyio.CancelScope(shield=True):
|
|
101
|
+
await session.rollback()
|
|
102
|
+
except BaseException:
|
|
100
103
|
return
|
|
@@ -17,6 +17,8 @@ class RequestLogEntry(DashboardModel):
|
|
|
17
17
|
error_code: str | None = None
|
|
18
18
|
error_message: str | None = None
|
|
19
19
|
tokens: int | None = None
|
|
20
|
+
cached_input_tokens: int | None = None
|
|
21
|
+
reasoning_effort: str | None = None
|
|
20
22
|
cost_usd: float | None = None
|
|
21
23
|
latency_ms: int | None = None
|
|
22
24
|
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from datetime import datetime
|
|
4
|
+
from typing import cast
|
|
4
5
|
|
|
5
|
-
from app.core.usage.logs import
|
|
6
|
+
from app.core.usage.logs import (
|
|
7
|
+
RequestLogLike,
|
|
8
|
+
cached_input_tokens_from_log,
|
|
9
|
+
cost_from_log,
|
|
10
|
+
total_tokens_from_log,
|
|
11
|
+
)
|
|
6
12
|
from app.db.models import RequestLog
|
|
7
13
|
from app.modules.request_logs.repository import RequestLogsRepository
|
|
8
14
|
from app.modules.request_logs.schemas import RequestLogEntry
|
|
@@ -63,15 +69,18 @@ def _log_status(log: RequestLog) -> str:
|
|
|
63
69
|
|
|
64
70
|
|
|
65
71
|
def _to_entry(log: RequestLog) -> RequestLogEntry:
|
|
72
|
+
log_like = cast(RequestLogLike, log)
|
|
66
73
|
return RequestLogEntry(
|
|
67
74
|
requested_at=log.requested_at,
|
|
68
75
|
account_id=log.account_id,
|
|
69
76
|
request_id=log.request_id,
|
|
70
77
|
model=log.model,
|
|
78
|
+
reasoning_effort=log.reasoning_effort,
|
|
71
79
|
status=_log_status(log),
|
|
72
80
|
error_code=log.error_code,
|
|
73
81
|
error_message=log.error_message,
|
|
74
|
-
tokens=total_tokens_from_log(
|
|
75
|
-
|
|
82
|
+
tokens=total_tokens_from_log(log_like),
|
|
83
|
+
cached_input_tokens=cached_input_tokens_from_log(log_like),
|
|
84
|
+
cost_usd=cost_from_log(log_like, precision=6),
|
|
76
85
|
latency_ms=log.latency_ms,
|
|
77
86
|
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Body, Depends
|
|
4
|
+
|
|
5
|
+
from app.dependencies import SettingsContext, get_settings_context
|
|
6
|
+
from app.modules.settings.schemas import DashboardSettingsResponse, DashboardSettingsUpdateRequest
|
|
7
|
+
from app.modules.settings.service import DashboardSettingsData
|
|
8
|
+
|
|
9
|
+
router = APIRouter(prefix="/api/settings", tags=["dashboard"])
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@router.get("", response_model=DashboardSettingsResponse)
|
|
13
|
+
async def get_settings(
|
|
14
|
+
context: SettingsContext = Depends(get_settings_context),
|
|
15
|
+
) -> DashboardSettingsResponse:
|
|
16
|
+
settings = await context.service.get_settings()
|
|
17
|
+
return DashboardSettingsResponse(
|
|
18
|
+
sticky_threads_enabled=settings.sticky_threads_enabled,
|
|
19
|
+
prefer_earlier_reset_accounts=settings.prefer_earlier_reset_accounts,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@router.put("", response_model=DashboardSettingsResponse)
|
|
24
|
+
async def update_settings(
|
|
25
|
+
payload: DashboardSettingsUpdateRequest = Body(...),
|
|
26
|
+
context: SettingsContext = Depends(get_settings_context),
|
|
27
|
+
) -> DashboardSettingsResponse:
|
|
28
|
+
updated = await context.service.update_settings(
|
|
29
|
+
DashboardSettingsData(
|
|
30
|
+
sticky_threads_enabled=payload.sticky_threads_enabled,
|
|
31
|
+
prefer_earlier_reset_accounts=payload.prefer_earlier_reset_accounts,
|
|
32
|
+
)
|
|
33
|
+
)
|
|
34
|
+
return DashboardSettingsResponse(
|
|
35
|
+
sticky_threads_enabled=updated.sticky_threads_enabled,
|
|
36
|
+
prefer_earlier_reset_accounts=updated.prefer_earlier_reset_accounts,
|
|
37
|
+
)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
4
|
+
|
|
5
|
+
from app.db.models import DashboardSettings
|
|
6
|
+
|
|
7
|
+
_SETTINGS_ID = 1
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SettingsRepository:
|
|
11
|
+
def __init__(self, session: AsyncSession) -> None:
|
|
12
|
+
self._session = session
|
|
13
|
+
|
|
14
|
+
async def get_or_create(self) -> DashboardSettings:
|
|
15
|
+
existing = await self._session.get(DashboardSettings, _SETTINGS_ID)
|
|
16
|
+
if existing is not None:
|
|
17
|
+
return existing
|
|
18
|
+
|
|
19
|
+
row = DashboardSettings(
|
|
20
|
+
id=_SETTINGS_ID,
|
|
21
|
+
sticky_threads_enabled=False,
|
|
22
|
+
prefer_earlier_reset_accounts=False,
|
|
23
|
+
)
|
|
24
|
+
self._session.add(row)
|
|
25
|
+
await self._session.commit()
|
|
26
|
+
await self._session.refresh(row)
|
|
27
|
+
return row
|
|
28
|
+
|
|
29
|
+
async def update(
|
|
30
|
+
self,
|
|
31
|
+
*,
|
|
32
|
+
sticky_threads_enabled: bool,
|
|
33
|
+
prefer_earlier_reset_accounts: bool,
|
|
34
|
+
) -> DashboardSettings:
|
|
35
|
+
settings = await self.get_or_create()
|
|
36
|
+
settings.sticky_threads_enabled = sticky_threads_enabled
|
|
37
|
+
settings.prefer_earlier_reset_accounts = prefer_earlier_reset_accounts
|
|
38
|
+
await self._session.commit()
|
|
39
|
+
await self._session.refresh(settings)
|
|
40
|
+
return settings
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from app.modules.shared.schemas import DashboardModel
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DashboardSettingsResponse(DashboardModel):
|
|
7
|
+
sticky_threads_enabled: bool
|
|
8
|
+
prefer_earlier_reset_accounts: bool
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DashboardSettingsUpdateRequest(DashboardModel):
|
|
12
|
+
sticky_threads_enabled: bool
|
|
13
|
+
prefer_earlier_reset_accounts: bool
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from app.modules.settings.repository import SettingsRepository
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True)
|
|
9
|
+
class DashboardSettingsData:
|
|
10
|
+
sticky_threads_enabled: bool
|
|
11
|
+
prefer_earlier_reset_accounts: bool
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SettingsService:
|
|
15
|
+
def __init__(self, repository: SettingsRepository) -> None:
|
|
16
|
+
self._repository = repository
|
|
17
|
+
|
|
18
|
+
async def get_settings(self) -> DashboardSettingsData:
|
|
19
|
+
row = await self._repository.get_or_create()
|
|
20
|
+
return DashboardSettingsData(
|
|
21
|
+
sticky_threads_enabled=row.sticky_threads_enabled,
|
|
22
|
+
prefer_earlier_reset_accounts=row.prefer_earlier_reset_accounts,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
async def update_settings(self, payload: DashboardSettingsData) -> DashboardSettingsData:
|
|
26
|
+
row = await self._repository.update(
|
|
27
|
+
sticky_threads_enabled=payload.sticky_threads_enabled,
|
|
28
|
+
prefer_earlier_reset_accounts=payload.prefer_earlier_reset_accounts,
|
|
29
|
+
)
|
|
30
|
+
return DashboardSettingsData(
|
|
31
|
+
sticky_threads_enabled=row.sticky_threads_enabled,
|
|
32
|
+
prefer_earlier_reset_accounts=row.prefer_earlier_reset_accounts,
|
|
33
|
+
)
|
app/modules/shared/schemas.py
CHANGED
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, field_serializer
|
|
4
6
|
from pydantic.alias_generators import to_camel
|
|
5
7
|
|
|
6
8
|
|
|
7
9
|
class DashboardModel(BaseModel):
|
|
8
|
-
model_config = ConfigDict(
|
|
10
|
+
model_config = ConfigDict(
|
|
11
|
+
alias_generator=to_camel,
|
|
12
|
+
populate_by_name=True,
|
|
13
|
+
ser_json_timedelta="iso8601",
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
@field_serializer("*", when_used="json")
|
|
17
|
+
def serialize_datetime_as_utc(value, _info):
|
|
18
|
+
if isinstance(value, datetime):
|
|
19
|
+
if value.tzinfo is None:
|
|
20
|
+
return value.isoformat() + "Z"
|
|
21
|
+
return value.isoformat().replace("+00:00", "Z")
|
|
22
|
+
return value
|
app/modules/usage/schemas.py
CHANGED
|
@@ -30,6 +30,7 @@ class UsageCost(DashboardModel):
|
|
|
30
30
|
class UsageMetrics(DashboardModel):
|
|
31
31
|
requests_7d: int | None = Field(default=None, alias="requests7d")
|
|
32
32
|
tokens_secondary_window: int | None = None
|
|
33
|
+
cached_tokens_secondary_window: int | None = None
|
|
33
34
|
error_rate_7d: float | None = Field(default=None, alias="errorRate7d")
|
|
34
35
|
top_error: str | None = None
|
|
35
36
|
|
app/modules/usage/service.py
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from datetime import timedelta
|
|
4
|
+
from typing import cast
|
|
4
5
|
|
|
5
6
|
from app.core import usage as usage_core
|
|
6
|
-
from app.core.usage.logs import
|
|
7
|
+
from app.core.usage.logs import (
|
|
8
|
+
RequestLogLike,
|
|
9
|
+
cached_input_tokens_from_log,
|
|
10
|
+
cost_from_log,
|
|
11
|
+
total_tokens_from_log,
|
|
12
|
+
usage_tokens_from_log,
|
|
13
|
+
)
|
|
7
14
|
from app.core.usage.pricing import CostItem, calculate_costs
|
|
8
15
|
from app.core.usage.types import (
|
|
9
16
|
UsageCostSummary,
|
|
@@ -15,7 +22,6 @@ from app.core.usage.types import (
|
|
|
15
22
|
from app.core.utils.time import from_epoch_seconds, utcnow
|
|
16
23
|
from app.db.models import Account, RequestLog
|
|
17
24
|
from app.modules.accounts.repository import AccountsRepository
|
|
18
|
-
from app.modules.proxy.usage_updater import UsageUpdater
|
|
19
25
|
from app.modules.request_logs.repository import RequestLogsRepository
|
|
20
26
|
from app.modules.usage.repository import UsageRepository
|
|
21
27
|
from app.modules.usage.schemas import (
|
|
@@ -28,6 +34,7 @@ from app.modules.usage.schemas import (
|
|
|
28
34
|
UsageWindow,
|
|
29
35
|
UsageWindowResponse,
|
|
30
36
|
)
|
|
37
|
+
from app.modules.usage.updater import UsageUpdater
|
|
31
38
|
|
|
32
39
|
|
|
33
40
|
class UsageService:
|
|
@@ -137,7 +144,7 @@ def _build_account_history(
|
|
|
137
144
|
for log in logs:
|
|
138
145
|
account_id = log.account_id
|
|
139
146
|
counts[account_id] = counts.get(account_id, 0) + 1
|
|
140
|
-
cost = cost_from_log(log)
|
|
147
|
+
cost = cost_from_log(cast(RequestLogLike, log))
|
|
141
148
|
if cost is None:
|
|
142
149
|
continue
|
|
143
150
|
costs[account_id] = costs.get(account_id, 0.0) + cost
|
|
@@ -166,7 +173,7 @@ def _build_account_history(
|
|
|
166
173
|
|
|
167
174
|
def _log_to_cost_item(log: RequestLog) -> CostItem | None:
|
|
168
175
|
model = log.model
|
|
169
|
-
usage = usage_tokens_from_log(log)
|
|
176
|
+
usage = usage_tokens_from_log(cast(RequestLogLike, log))
|
|
170
177
|
if not model or not usage:
|
|
171
178
|
return None
|
|
172
179
|
return CostItem(model=model, usage=usage)
|
|
@@ -180,9 +187,11 @@ def _usage_metrics(logs_secondary: list[RequestLog]) -> UsageMetricsSummary:
|
|
|
180
187
|
error_rate = len(error_logs) / total_requests
|
|
181
188
|
top_error = _top_error_code(error_logs)
|
|
182
189
|
tokens_secondary = _sum_tokens(logs_secondary)
|
|
190
|
+
cached_tokens_secondary = _sum_cached_input_tokens(logs_secondary)
|
|
183
191
|
return UsageMetricsSummary(
|
|
184
192
|
requests_7d=total_requests,
|
|
185
193
|
tokens_secondary_window=tokens_secondary,
|
|
194
|
+
cached_tokens_secondary_window=cached_tokens_secondary,
|
|
186
195
|
error_rate_7d=error_rate,
|
|
187
196
|
top_error=top_error,
|
|
188
197
|
)
|
|
@@ -191,7 +200,14 @@ def _usage_metrics(logs_secondary: list[RequestLog]) -> UsageMetricsSummary:
|
|
|
191
200
|
def _sum_tokens(logs: list[RequestLog]) -> int:
|
|
192
201
|
total = 0
|
|
193
202
|
for log in logs:
|
|
194
|
-
total += total_tokens_from_log(log) or 0
|
|
203
|
+
total += total_tokens_from_log(cast(RequestLogLike, log)) or 0
|
|
204
|
+
return total
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _sum_cached_input_tokens(logs: list[RequestLog]) -> int:
|
|
208
|
+
total = 0
|
|
209
|
+
for log in logs:
|
|
210
|
+
total += cached_input_tokens_from_log(cast(RequestLogLike, log)) or 0
|
|
195
211
|
return total
|
|
196
212
|
|
|
197
213
|
|
|
@@ -232,7 +248,7 @@ def _window_snapshot_to_model(snapshot: UsageWindowSnapshot) -> UsageWindow:
|
|
|
232
248
|
def _cost_summary_to_model(cost: UsageCostSummary) -> UsageCost:
|
|
233
249
|
return UsageCost(
|
|
234
250
|
currency=cost.currency,
|
|
235
|
-
|
|
251
|
+
totalUsd7d=cost.total_usd_7d,
|
|
236
252
|
by_model=[UsageCostByModel(model=item.model, usd=item.usd) for item in cost.by_model],
|
|
237
253
|
)
|
|
238
254
|
|
|
@@ -241,6 +257,7 @@ def _metrics_summary_to_model(metrics: UsageMetricsSummary) -> UsageMetrics:
|
|
|
241
257
|
return UsageMetrics(
|
|
242
258
|
requests_7d=metrics.requests_7d,
|
|
243
259
|
tokens_secondary_window=metrics.tokens_secondary_window,
|
|
260
|
+
cached_tokens_secondary_window=metrics.cached_tokens_secondary_window,
|
|
244
261
|
error_rate_7d=metrics.error_rate_7d,
|
|
245
262
|
top_error=metrics.top_error,
|
|
246
263
|
)
|
|
@@ -2,7 +2,9 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
4
|
import math
|
|
5
|
-
from
|
|
5
|
+
from collections import Counter
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Mapping, Protocol
|
|
6
8
|
|
|
7
9
|
from app.core.auth.refresh import RefreshError
|
|
8
10
|
from app.core.clients.usage import UsageFetchError, fetch_usage
|
|
@@ -12,17 +14,33 @@ from app.core.usage.models import UsagePayload
|
|
|
12
14
|
from app.core.utils.request_id import get_request_id
|
|
13
15
|
from app.core.utils.time import utcnow
|
|
14
16
|
from app.db.models import Account, AccountStatus, UsageHistory
|
|
17
|
+
from app.modules.accounts.auth_manager import AuthManager
|
|
15
18
|
from app.modules.accounts.repository import AccountsRepository
|
|
16
|
-
from app.modules.proxy.auth_manager import AuthManager
|
|
17
|
-
from app.modules.usage.repository import UsageRepository
|
|
18
19
|
|
|
19
20
|
logger = logging.getLogger(__name__)
|
|
20
21
|
|
|
21
22
|
|
|
23
|
+
class UsageRepositoryPort(Protocol):
|
|
24
|
+
async def add_entry(
|
|
25
|
+
self,
|
|
26
|
+
account_id: str,
|
|
27
|
+
used_percent: float,
|
|
28
|
+
input_tokens: int | None = None,
|
|
29
|
+
output_tokens: int | None = None,
|
|
30
|
+
recorded_at: datetime | None = None,
|
|
31
|
+
window: str | None = None,
|
|
32
|
+
reset_at: int | None = None,
|
|
33
|
+
window_minutes: int | None = None,
|
|
34
|
+
credits_has: bool | None = None,
|
|
35
|
+
credits_unlimited: bool | None = None,
|
|
36
|
+
credits_balance: float | None = None,
|
|
37
|
+
) -> UsageHistory | None: ...
|
|
38
|
+
|
|
39
|
+
|
|
22
40
|
class UsageUpdater:
|
|
23
41
|
def __init__(
|
|
24
42
|
self,
|
|
25
|
-
usage_repo:
|
|
43
|
+
usage_repo: UsageRepositoryPort,
|
|
26
44
|
accounts_repo: AccountsRepository | None = None,
|
|
27
45
|
) -> None:
|
|
28
46
|
self._usage_repo = usage_repo
|
|
@@ -38,6 +56,7 @@ class UsageUpdater:
|
|
|
38
56
|
if not settings.usage_refresh_enabled:
|
|
39
57
|
return
|
|
40
58
|
|
|
59
|
+
shared_chatgpt_account_ids = _shared_chatgpt_account_ids(accounts)
|
|
41
60
|
now = utcnow()
|
|
42
61
|
interval = settings.usage_refresh_interval_seconds
|
|
43
62
|
for account in accounts:
|
|
@@ -46,11 +65,16 @@ class UsageUpdater:
|
|
|
46
65
|
latest = latest_usage.get(account.id)
|
|
47
66
|
if latest and (now - latest.recorded_at).total_seconds() < interval:
|
|
48
67
|
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
|
+
)
|
|
49
73
|
# NOTE: AsyncSession is not safe for concurrent use. Run sequentially
|
|
50
74
|
# within the request-scoped session to avoid PK collisions and
|
|
51
75
|
# flush-time warnings (SAWarning: Session.add during flush).
|
|
52
76
|
try:
|
|
53
|
-
await self._refresh_account(account)
|
|
77
|
+
await self._refresh_account(account, usage_account_id=usage_account_id)
|
|
54
78
|
except Exception as exc:
|
|
55
79
|
logger.warning(
|
|
56
80
|
"Usage refresh failed account_id=%s request_id=%s error=%s",
|
|
@@ -62,12 +86,12 @@ class UsageUpdater:
|
|
|
62
86
|
# swallow per-account failures so the whole refresh loop keeps going
|
|
63
87
|
continue
|
|
64
88
|
|
|
65
|
-
async def _refresh_account(self, account: Account) -> None:
|
|
89
|
+
async def _refresh_account(self, account: Account, *, usage_account_id: str | None) -> None:
|
|
66
90
|
access_token = self._encryptor.decrypt(account.access_token_encrypted)
|
|
67
91
|
try:
|
|
68
92
|
payload = await fetch_usage(
|
|
69
93
|
access_token=access_token,
|
|
70
|
-
account_id=
|
|
94
|
+
account_id=usage_account_id,
|
|
71
95
|
)
|
|
72
96
|
except UsageFetchError as exc:
|
|
73
97
|
if exc.status_code != 401 or not self._auth_manager:
|
|
@@ -80,7 +104,7 @@ class UsageUpdater:
|
|
|
80
104
|
try:
|
|
81
105
|
payload = await fetch_usage(
|
|
82
106
|
access_token=access_token,
|
|
83
|
-
account_id=
|
|
107
|
+
account_id=usage_account_id,
|
|
84
108
|
)
|
|
85
109
|
except UsageFetchError:
|
|
86
110
|
return
|
|
@@ -145,3 +169,8 @@ def _window_minutes(limit_seconds: int | None) -> int | None:
|
|
|
145
169
|
if not limit_seconds or limit_seconds <= 0:
|
|
146
170
|
return None
|
|
147
171
|
return max(1, math.ceil(limit_seconds / 60))
|
|
172
|
+
|
|
173
|
+
|
|
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}
|
app/static/7.css
CHANGED
|
@@ -48,6 +48,9 @@
|
|
|
48
48
|
--w7-s-icon: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggc3Ryb2tlPSIjMjA3MGI5IiBkPSJNMTAuNSAxQzguMDIgMSA2IDMuMDIgNiA1LjVhNC40NSA0LjQ1IDAgMCAwIDEgMi43OTNMMi4wMjMgMTMuMjdsLjcwNC43MUw3LjcwNyA5Yy43Ny42MTcgMS43MzQgMSAyLjc5MyAxIDIuNDggMCA0LjUtMi4wMiA0LjUtNC41UzEyLjk4IDEgMTAuNSAxWm0wIDFDMTIuNDM4IDIgMTQgMy41NjMgMTQgNS41IDE0IDcuNDM4IDEyLjQzNyA5IDEwLjUgOUEzLjQ5NCAzLjQ5NCAwIDAgMSA3IDUuNUM3IDMuNTYyIDguNTYzIDIgMTAuNSAyWiIvPjwvc3ZnPg==");
|
|
49
49
|
--w7-s-bg: var(--w7-s-icon) no-repeat center;
|
|
50
50
|
|
|
51
|
+
/* checkbox */
|
|
52
|
+
--w7-cb-size: 14px;
|
|
53
|
+
|
|
51
54
|
/* radio */
|
|
52
55
|
--w7-rd-size: 14px;
|
|
53
56
|
--w7-rdl-space: 6px;
|
|
@@ -416,6 +419,76 @@ table>tbody>tr>:not(:last-child) {
|
|
|
416
419
|
}
|
|
417
420
|
}
|
|
418
421
|
|
|
422
|
+
input[type=checkbox] {
|
|
423
|
+
appearance: none;
|
|
424
|
+
-webkit-appearance: none;
|
|
425
|
+
-moz-appearance: none;
|
|
426
|
+
background: none;
|
|
427
|
+
border: none;
|
|
428
|
+
font: var(--w7-font);
|
|
429
|
+
margin: 0;
|
|
430
|
+
opacity: 0
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
input[type=checkbox]+label {
|
|
434
|
+
align-items: center;
|
|
435
|
+
display: inline-flex;
|
|
436
|
+
font: var(--w7-font);
|
|
437
|
+
position: relative
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
input[type=checkbox]+label:before {
|
|
441
|
+
background: #f6f6f6;
|
|
442
|
+
border: 1px solid var(--w7-el-bd);
|
|
443
|
+
box-shadow: inset 0 0 0 1px var(--w7-el-bg-d),inset 1px 1px 0 1px #aeaeae,inset -1px -1px 0 1px #ddd,inset 3px 3px 6px #ccc;
|
|
444
|
+
box-sizing: border-box;
|
|
445
|
+
content: "";
|
|
446
|
+
display: inline-block;
|
|
447
|
+
height: var(--w7-cb-size);
|
|
448
|
+
margin-right: 6px;
|
|
449
|
+
transition: .4s;
|
|
450
|
+
width: var(--w7-cb-size)
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
input[type=checkbox]+label:hover:before {
|
|
454
|
+
background: #e9f7fe;
|
|
455
|
+
border-color: var(--w7-el-bd-h);
|
|
456
|
+
box-shadow: inset 0 0 0 1px #def9fa,inset 1px 1px 0 1px #79c6f9,inset -1px -1px 0 1px #c6e9fc,inset 3px 3px 6px #b1dffd
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
input[type=checkbox]:focus-visible+label {
|
|
460
|
+
outline: 1px dotted #000
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
input[type=checkbox]:checked+label:after {
|
|
464
|
+
color: #4a5f97;
|
|
465
|
+
content: "\2714";
|
|
466
|
+
display: block;
|
|
467
|
+
font-weight: 700;
|
|
468
|
+
left: 2px;
|
|
469
|
+
position: absolute;
|
|
470
|
+
top: 0
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
input[type=checkbox]:disabled+label {
|
|
474
|
+
color: #6d6d6d
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
input[type=checkbox]:disabled+label:before {
|
|
478
|
+
background: linear-gradient(to bottom right,#f0f0f0,#fbfbfb);
|
|
479
|
+
border: 1px solid #b1b1b1;
|
|
480
|
+
box-shadow: none;
|
|
481
|
+
content: "";
|
|
482
|
+
display: inline-block;
|
|
483
|
+
height: var(--w7-cb-size);
|
|
484
|
+
margin-right: 6px;
|
|
485
|
+
width: var(--w7-cb-size)
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
input[type=checkbox]:disabled+label:after {
|
|
489
|
+
color: #bfbfbf
|
|
490
|
+
}
|
|
491
|
+
|
|
419
492
|
input[type=radio] {
|
|
420
493
|
appearance: none;
|
|
421
494
|
-webkit-appearance: none;
|
app/static/index.css
CHANGED
|
@@ -304,17 +304,46 @@ body {
|
|
|
304
304
|
}
|
|
305
305
|
|
|
306
306
|
.legend li {
|
|
307
|
-
display:
|
|
308
|
-
|
|
307
|
+
display: grid;
|
|
308
|
+
grid-template-columns: minmax(0, 1fr) 120px;
|
|
309
|
+
align-items: center;
|
|
309
310
|
gap: 8px;
|
|
310
311
|
font-size: 12px;
|
|
311
312
|
width: 100%;
|
|
312
313
|
}
|
|
313
314
|
|
|
314
|
-
.legend
|
|
315
|
-
display:
|
|
315
|
+
.legend-label {
|
|
316
|
+
display: flex;
|
|
316
317
|
align-items: center;
|
|
317
318
|
gap: 6px;
|
|
319
|
+
min-width: 0;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
.legend-label-text {
|
|
323
|
+
flex: 1 1 auto;
|
|
324
|
+
min-width: 0;
|
|
325
|
+
overflow: hidden;
|
|
326
|
+
text-overflow: ellipsis;
|
|
327
|
+
white-space: nowrap;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
.legend-detail {
|
|
331
|
+
white-space: nowrap;
|
|
332
|
+
display: flex;
|
|
333
|
+
align-items: baseline;
|
|
334
|
+
justify-content: space-between;
|
|
335
|
+
gap: 6px;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
.legend-detail-label {
|
|
339
|
+
min-width: 0;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
.legend-detail-value {
|
|
343
|
+
min-width: 4ch;
|
|
344
|
+
text-align: right;
|
|
345
|
+
font-variant-numeric: tabular-nums;
|
|
346
|
+
font-feature-settings: "tnum" 1;
|
|
318
347
|
}
|
|
319
348
|
|
|
320
349
|
|
app/static/index.html
CHANGED
|
@@ -78,11 +78,14 @@
|
|
|
78
78
|
<ul class="legend">
|
|
79
79
|
<template x-for="item in donut.items" :key="item.label">
|
|
80
80
|
<li>
|
|
81
|
-
<span>
|
|
81
|
+
<span class="legend-label">
|
|
82
82
|
<i :style="{ '--legend-color': item.color }"></i>
|
|
83
|
-
<span x-text="item.label"></span>
|
|
83
|
+
<span class="legend-label-text" x-text="item.label" :title="item.label"></span>
|
|
84
|
+
</span>
|
|
85
|
+
<span class="legend-detail">
|
|
86
|
+
<span class="legend-detail-label" x-text="item.detailLabel"></span>
|
|
87
|
+
<span class="legend-detail-value" x-text="item.detailValue"></span>
|
|
84
88
|
</span>
|
|
85
|
-
<span x-text="item.detail"></span>
|
|
86
89
|
</li>
|
|
87
90
|
</template>
|
|
88
91
|
</ul>
|
|
@@ -297,6 +300,50 @@
|
|
|
297
300
|
</div>
|
|
298
301
|
</div>
|
|
299
302
|
</article>
|
|
303
|
+
|
|
304
|
+
<article role="tabpanel" id="tab-settings" :hidden="view !== 'settings'">
|
|
305
|
+
<div class="panel">
|
|
306
|
+
<h3>Routing settings</h3>
|
|
307
|
+
<p class="text-muted">Toggle routing features. When both options are off, accounts are selected by
|
|
308
|
+
balancing usage evenly.</p>
|
|
309
|
+
|
|
310
|
+
<fieldset style="margin-top: 12px">
|
|
311
|
+
<legend>Sticky threads</legend>
|
|
312
|
+
<input
|
|
313
|
+
id="sticky-threads-toggle"
|
|
314
|
+
type="checkbox"
|
|
315
|
+
x-model="settings.stickyThreadsEnabled"
|
|
316
|
+
aria-describedby="sticky-threads-help"
|
|
317
|
+
>
|
|
318
|
+
<label for="sticky-threads-toggle">Enable sticky threads (reuse the same upstream account per conversation)</label>
|
|
319
|
+
<div id="sticky-threads-help" class="text-muted" style="margin-top: 6px">
|
|
320
|
+
When enabled, requests with a prompt cache key stay pinned to the same upstream account unless the
|
|
321
|
+
pinned account becomes unavailable.
|
|
322
|
+
</div>
|
|
323
|
+
</fieldset>
|
|
324
|
+
|
|
325
|
+
<fieldset style="margin-top: 12px">
|
|
326
|
+
<legend>Reset priority</legend>
|
|
327
|
+
<input
|
|
328
|
+
id="reset-priority-toggle"
|
|
329
|
+
type="checkbox"
|
|
330
|
+
x-model="settings.preferEarlierResetAccounts"
|
|
331
|
+
aria-describedby="reset-priority-help"
|
|
332
|
+
>
|
|
333
|
+
<label for="reset-priority-toggle">Prefer accounts that reset earlier first</label>
|
|
334
|
+
<div id="reset-priority-help" class="text-muted" style="margin-top: 6px">
|
|
335
|
+
When enabled, the load balancer prefers accounts whose secondary quota resets sooner, then balances
|
|
336
|
+
usage.
|
|
337
|
+
</div>
|
|
338
|
+
</fieldset>
|
|
339
|
+
|
|
340
|
+
<div class="inline-actions" style="margin-top: 12px">
|
|
341
|
+
<button type="button" @click="saveSettings" :disabled="settings.isSaving">
|
|
342
|
+
<span>Save</span>
|
|
343
|
+
</button>
|
|
344
|
+
</div>
|
|
345
|
+
</div>
|
|
346
|
+
</article>
|
|
300
347
|
</section>
|
|
301
348
|
</div>
|
|
302
349
|
<div class="status-bar">
|
|
@@ -454,4 +501,4 @@
|
|
|
454
501
|
<script defer src="https://unpkg.com/alpinejs@3.13.2/dist/cdn.min.js"></script>
|
|
455
502
|
</body>
|
|
456
503
|
|
|
457
|
-
</html>
|
|
504
|
+
</html>
|