codex-lb 0.1.5__py3-none-any.whl → 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. app/__init__.py +1 -1
  2. app/core/auth/__init__.py +12 -1
  3. app/core/balancer/logic.py +44 -7
  4. app/core/clients/proxy.py +2 -4
  5. app/core/config/settings.py +4 -1
  6. app/core/plan_types.py +64 -0
  7. app/core/types.py +4 -2
  8. app/core/usage/__init__.py +5 -2
  9. app/core/usage/logs.py +12 -2
  10. app/core/usage/quota.py +64 -0
  11. app/core/usage/types.py +3 -2
  12. app/core/utils/sse.py +6 -2
  13. app/db/migrations/__init__.py +91 -0
  14. app/db/migrations/versions/__init__.py +1 -0
  15. app/db/migrations/versions/add_accounts_chatgpt_account_id.py +29 -0
  16. app/db/migrations/versions/add_accounts_reset_at.py +29 -0
  17. app/db/migrations/versions/add_dashboard_settings.py +31 -0
  18. app/db/migrations/versions/add_request_logs_reasoning_effort.py +21 -0
  19. app/db/migrations/versions/normalize_account_plan_types.py +17 -0
  20. app/db/models.py +33 -0
  21. app/db/session.py +85 -11
  22. app/dependencies.py +27 -9
  23. app/main.py +15 -6
  24. app/modules/accounts/auth_manager.py +121 -0
  25. app/modules/accounts/repository.py +14 -6
  26. app/modules/accounts/service.py +14 -9
  27. app/modules/health/api.py +5 -3
  28. app/modules/health/schemas.py +9 -0
  29. app/modules/oauth/service.py +9 -4
  30. app/modules/proxy/helpers.py +285 -0
  31. app/modules/proxy/load_balancer.py +86 -41
  32. app/modules/proxy/service.py +172 -318
  33. app/modules/proxy/sticky_repository.py +56 -0
  34. app/modules/request_logs/repository.py +6 -3
  35. app/modules/request_logs/schemas.py +2 -0
  36. app/modules/request_logs/service.py +12 -3
  37. app/modules/settings/__init__.py +1 -0
  38. app/modules/settings/api.py +37 -0
  39. app/modules/settings/repository.py +40 -0
  40. app/modules/settings/schemas.py +13 -0
  41. app/modules/settings/service.py +33 -0
  42. app/modules/shared/schemas.py +16 -2
  43. app/modules/usage/schemas.py +1 -0
  44. app/modules/usage/service.py +23 -6
  45. app/modules/{proxy/usage_updater.py → usage/updater.py} +37 -8
  46. app/static/7.css +73 -0
  47. app/static/index.css +33 -4
  48. app/static/index.html +51 -4
  49. app/static/index.js +254 -32
  50. {codex_lb-0.1.5.dist-info → codex_lb-0.3.0.dist-info}/METADATA +2 -2
  51. codex_lb-0.3.0.dist-info/RECORD +97 -0
  52. app/modules/proxy/auth_manager.py +0 -51
  53. codex_lb-0.1.5.dist-info/RECORD +0 -80
  54. {codex_lb-0.1.5.dist-info → codex_lb-0.3.0.dist-info}/WHEEL +0 -0
  55. {codex_lb-0.1.5.dist-info → codex_lb-0.3.0.dist-info}/entry_points.txt +0 -0
  56. {codex_lb-0.1.5.dist-info → codex_lb-0.3.0.dist-info}/licenses/LICENSE +0 -0
@@ -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
- await asyncio.shield(session.rollback())
99
- except Exception:
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 cost_from_log, total_tokens_from_log
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(log),
75
- cost_usd=cost_from_log(log, precision=6),
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
+ )
@@ -1,8 +1,22 @@
1
1
  from __future__ import annotations
2
2
 
3
- from pydantic import BaseModel, ConfigDict
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(alias_generator=to_camel, populate_by_name=True)
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
@@ -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
 
@@ -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 cost_from_log, total_tokens_from_log, usage_tokens_from_log
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
- total_usd_7d=cost.total_usd_7d,
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 typing import Mapping
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: UsageRepository,
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=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=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: flex;
308
- justify-content: space-between;
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 span {
315
- display: inline-flex;
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>