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.
Files changed (37) hide show
  1. app/core/clients/proxy.py +33 -3
  2. app/core/config/settings.py +9 -8
  3. app/core/handlers/__init__.py +3 -0
  4. app/core/handlers/exceptions.py +39 -0
  5. app/core/middleware/__init__.py +9 -0
  6. app/core/middleware/api_errors.py +33 -0
  7. app/core/middleware/request_decompression.py +101 -0
  8. app/core/middleware/request_id.py +27 -0
  9. app/core/openai/chat_requests.py +172 -0
  10. app/core/openai/chat_responses.py +534 -0
  11. app/core/openai/message_coercion.py +60 -0
  12. app/core/openai/models_catalog.py +72 -0
  13. app/core/openai/requests.py +23 -5
  14. app/core/openai/v1_requests.py +92 -0
  15. app/db/models.py +3 -3
  16. app/db/session.py +25 -8
  17. app/dependencies.py +43 -16
  18. app/main.py +13 -67
  19. app/modules/accounts/repository.py +25 -10
  20. app/modules/proxy/api.py +94 -0
  21. app/modules/proxy/load_balancer.py +75 -58
  22. app/modules/proxy/repo_bundle.py +23 -0
  23. app/modules/proxy/service.py +127 -102
  24. app/modules/request_logs/api.py +61 -7
  25. app/modules/request_logs/repository.py +131 -16
  26. app/modules/request_logs/schemas.py +11 -2
  27. app/modules/request_logs/service.py +97 -20
  28. app/modules/usage/service.py +65 -4
  29. app/modules/usage/updater.py +58 -26
  30. app/static/index.css +378 -1
  31. app/static/index.html +183 -8
  32. app/static/index.js +308 -13
  33. {codex_lb-0.3.1.dist-info → codex_lb-0.5.0.dist-info}/METADATA +42 -3
  34. {codex_lb-0.3.1.dist-info → codex_lb-0.5.0.dist-info}/RECORD +37 -25
  35. {codex_lb-0.3.1.dist-info → codex_lb-0.5.0.dist-info}/WHEEL +0 -0
  36. {codex_lb-0.3.1.dist-info → codex_lb-0.5.0.dist-info}/entry_points.txt +0 -0
  37. {codex_lb-0.3.1.dist-info → codex_lb-0.5.0.dist-info}/licenses/LICENSE +0 -0
@@ -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
app/static/index.css CHANGED
@@ -90,6 +90,14 @@ body {
90
90
  -webkit-font-smoothing: antialiased;
91
91
  }
92
92
 
93
+ body[data-theme="dark"] {
94
+ color-scheme: dark;
95
+ }
96
+
97
+ body[data-theme="light"] {
98
+ color-scheme: light;
99
+ }
100
+
93
101
  .app-shell {
94
102
  display: flex;
95
103
  flex-direction: column;
@@ -424,6 +432,11 @@ body {
424
432
  color: var(--status-error-text);
425
433
  }
426
434
 
435
+ .status-pill.error {
436
+ background: var(--status-error-bg);
437
+ color: var(--status-error-text);
438
+ }
439
+
427
440
  .status-pill.deactivated,
428
441
  .status-pill.paused {
429
442
  background: var(--status-paused-bg);
@@ -585,6 +598,306 @@ body {
585
598
  background: var(--accent-primary-hover);
586
599
  }
587
600
 
601
+ .controls-toolbar {
602
+ display: flex;
603
+ flex-wrap: wrap;
604
+ gap: 12px;
605
+ margin-bottom: 16px;
606
+ align-items: center;
607
+ justify-content: space-between;
608
+ }
609
+
610
+ .controls-group {
611
+ display: flex;
612
+ flex-wrap: wrap;
613
+ gap: 8px;
614
+ align-items: center;
615
+ }
616
+
617
+ .filter-input {
618
+ background: var(--bg-surface);
619
+ border: 1px solid var(--border-subtle);
620
+ color: var(--text-main);
621
+ padding: 8px 12px;
622
+ border-radius: 6px;
623
+ font-size: 13px;
624
+ outline: none;
625
+ min-width: 120px;
626
+ }
627
+
628
+ .filter-input:focus {
629
+ border-color: var(--accent-primary);
630
+ }
631
+
632
+ .filter-select {
633
+ background: var(--bg-surface);
634
+ border: 1px solid var(--border-subtle);
635
+ color: var(--text-main);
636
+ padding: 8px 32px 8px 12px;
637
+ border-radius: 6px;
638
+ font-size: 13px;
639
+ outline: none;
640
+ -webkit-appearance: none;
641
+ appearance: none;
642
+ background-image: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%2358A6FF%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.5-12.8z%22%2F%3E%3C%2Fsvg%3E");
643
+ background-repeat: no-repeat;
644
+ background-position: right 10px top 50%;
645
+ background-size: 10px auto;
646
+ }
647
+
648
+ .filter-select[multiple] {
649
+ padding-right: 12px;
650
+ background-image: none;
651
+ }
652
+
653
+ .filter-select option {
654
+ background: var(--bg-surface);
655
+ color: var(--text-main);
656
+ }
657
+
658
+ .single-select {
659
+ position: relative;
660
+ }
661
+
662
+ .single-select-trigger {
663
+ display: inline-flex;
664
+ align-items: center;
665
+ justify-content: space-between;
666
+ gap: 12px;
667
+ min-width: 120px;
668
+ cursor: pointer;
669
+ position: relative;
670
+ background-image: none;
671
+ }
672
+
673
+ .single-select-trigger::after {
674
+ content: "";
675
+ position: absolute;
676
+ right: 10px;
677
+ top: 50%;
678
+ width: 10px;
679
+ height: 10px;
680
+ background-image: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%2358A6FF%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.5-12.8z%22%2F%3E%3C%2Fsvg%3E");
681
+ background-repeat: no-repeat;
682
+ background-size: contain;
683
+ transform: translateY(-50%) rotate(0deg);
684
+ transition: transform 0.25s ease;
685
+ }
686
+
687
+ .single-select-trigger[aria-expanded="true"] {
688
+ border-color: var(--accent-primary);
689
+ box-shadow: 0 0 0 1px var(--accent-primary);
690
+ }
691
+
692
+ .single-select-trigger[aria-expanded="true"]::after {
693
+ transform: translateY(-50%) rotate(180deg);
694
+ }
695
+
696
+ .single-select-menu {
697
+ position: absolute;
698
+ top: calc(100% + 8px);
699
+ left: 0;
700
+ z-index: 50;
701
+ width: max-content;
702
+ min-width: 140px;
703
+ padding: 8px;
704
+ padding-top: 12px;
705
+ border-radius: 10px;
706
+ border: 1px solid var(--border-subtle);
707
+ background: var(--bg-panel);
708
+ box-shadow: var(--shadow-md);
709
+ }
710
+
711
+ .single-select-menu::before {
712
+ content: "";
713
+ position: absolute;
714
+ top: -6px;
715
+ left: 16px;
716
+ width: 10px;
717
+ height: 10px;
718
+ background: var(--bg-panel);
719
+ border-left: 1px solid var(--border-subtle);
720
+ border-top: 1px solid var(--border-subtle);
721
+ transform: rotate(45deg);
722
+ }
723
+
724
+ .single-select-item {
725
+ display: flex;
726
+ align-items: center;
727
+ gap: 10px;
728
+ padding: 8px 10px;
729
+ border-radius: 8px;
730
+ cursor: pointer;
731
+ user-select: none;
732
+ color: var(--text-main);
733
+ font-size: 13px;
734
+ }
735
+
736
+ .single-select-item:hover {
737
+ background: var(--bg-list-hover);
738
+ }
739
+
740
+ .single-select-item.is-selected {
741
+ background: var(--bg-list-selected);
742
+ }
743
+
744
+ .single-select-item input[type="radio"] {
745
+ display: none;
746
+ }
747
+
748
+ .multi-select {
749
+ position: relative;
750
+ }
751
+
752
+ .multi-select-trigger {
753
+ display: inline-flex;
754
+ align-items: center;
755
+ justify-content: space-between;
756
+ gap: 12px;
757
+ min-width: 180px;
758
+ cursor: pointer;
759
+ position: relative;
760
+ background-image: none;
761
+ }
762
+
763
+ .multi-select-trigger::after {
764
+ content: "";
765
+ position: absolute;
766
+ right: 10px;
767
+ top: 50%;
768
+ width: 10px;
769
+ height: 10px;
770
+ background-image: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%2358A6FF%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.5-12.8z%22%2F%3E%3C%2Fsvg%3E");
771
+ background-repeat: no-repeat;
772
+ background-size: contain;
773
+ transform: translateY(-50%) rotate(0deg);
774
+ transition: transform 0.25s ease;
775
+ }
776
+
777
+ .multi-select-trigger:disabled {
778
+ opacity: 0.7;
779
+ cursor: not-allowed;
780
+ }
781
+
782
+ .multi-select-trigger[aria-expanded="true"] {
783
+ border-color: var(--accent-primary);
784
+ box-shadow: 0 0 0 1px var(--accent-primary);
785
+ }
786
+
787
+ .multi-select-trigger[aria-expanded="true"]::after {
788
+ transform: translateY(-50%) rotate(180deg);
789
+ }
790
+
791
+
792
+ .multi-select-menu {
793
+ position: absolute;
794
+ top: calc(100% + 8px);
795
+ left: 0;
796
+ z-index: 50;
797
+ width: max-content;
798
+ min-width: 220px;
799
+ max-width: 360px;
800
+ padding: 8px;
801
+ padding-top: 12px;
802
+ border-radius: 10px;
803
+ border: 1px solid var(--border-subtle);
804
+ background: var(--bg-panel);
805
+ box-shadow: var(--shadow-md);
806
+ overflow: visible;
807
+ }
808
+
809
+ .multi-select-scroller {
810
+ max-height: 280px;
811
+ overflow-y: auto;
812
+ overflow-x: hidden;
813
+ /* Add padding to ensure scrollbar doesn't overlap content tightly if needed,
814
+ but we removed padding from parent. Wait, parent has padding 8px.
815
+ The scroller is inside. */
816
+ }
817
+
818
+ .multi-select-menu::before {
819
+ content: "";
820
+ position: absolute;
821
+ top: -6px;
822
+ left: 16px;
823
+ width: 10px;
824
+ height: 10px;
825
+ background: var(--bg-panel);
826
+ border-left: 1px solid var(--border-subtle);
827
+ border-top: 1px solid var(--border-subtle);
828
+ transform: rotate(45deg);
829
+ }
830
+
831
+ .multi-select-actions {
832
+ display: flex;
833
+ justify-content: flex-end;
834
+ margin-bottom: 6px;
835
+ }
836
+
837
+ .multi-select-action {
838
+ background: transparent;
839
+ border: 1px solid var(--border-subtle);
840
+ color: var(--text-muted);
841
+ padding: 6px 10px;
842
+ border-radius: 8px;
843
+ font-size: 12px;
844
+ cursor: pointer;
845
+ }
846
+
847
+ .multi-select-action:hover {
848
+ border-color: var(--border-strong);
849
+ color: var(--text-main);
850
+ }
851
+
852
+ .multi-select-item {
853
+ display: flex;
854
+ align-items: center;
855
+ gap: 10px;
856
+ padding: 8px 10px;
857
+ border-radius: 8px;
858
+ cursor: pointer;
859
+ user-select: none;
860
+ }
861
+
862
+ .multi-select-item:hover {
863
+ background: var(--bg-list-hover);
864
+ }
865
+
866
+ .multi-select-item input[type="checkbox"] {
867
+ accent-color: var(--accent-primary);
868
+ }
869
+
870
+ .multi-select-label {
871
+ color: var(--text-main);
872
+ font-size: 13px;
873
+ white-space: nowrap;
874
+ overflow: hidden;
875
+ text-overflow: ellipsis;
876
+ max-width: 280px;
877
+ }
878
+
879
+ .filter-apply {
880
+ font-size: 13px;
881
+ padding: 8px 12px;
882
+ border-radius: 6px;
883
+ border: 1px solid var(--accent-primary);
884
+ background: var(--accent-primary);
885
+ color: var(--accent-primary-text);
886
+ cursor: pointer;
887
+ font-weight: 500;
888
+ transition: all 0.2s;
889
+ }
890
+
891
+ .filter-apply:hover {
892
+ border-color: var(--accent-primary-hover);
893
+ background: var(--accent-primary-hover);
894
+ }
895
+
896
+ .filter-apply:disabled {
897
+ opacity: 0.6;
898
+ cursor: not-allowed;
899
+ }
900
+
588
901
  .table-wrap {
589
902
  width: 100%;
590
903
  overflow-x: auto;
@@ -592,6 +905,18 @@ body {
592
905
  border-radius: 8px;
593
906
  }
594
907
 
908
+ .table-wrap--requests th,
909
+ .table-wrap--requests td {
910
+ text-align: center;
911
+ }
912
+
913
+ .table-wrap--requests td:nth-child(2),
914
+ .table-wrap--requests td:nth-child(5) {
915
+ text-align: left;
916
+ }
917
+
918
+
919
+
595
920
  table {
596
921
  width: 100%;
597
922
  border-collapse: collapse;
@@ -606,6 +931,56 @@ table {
606
931
  text-overflow: ellipsis;
607
932
  }
608
933
 
934
+ .error-cell {
935
+ display: flex;
936
+ align-items: flex-start;
937
+ gap: 6px;
938
+ min-width: 0;
939
+ }
940
+
941
+ .error-cell.placeholder {
942
+ justify-content: center;
943
+ }
944
+
945
+ .error-cell.placeholder .error-text {
946
+ text-align: center;
947
+ }
948
+
949
+ .error-text {
950
+ flex: 1;
951
+ min-width: 0;
952
+ }
953
+
954
+ .error-text.truncated {
955
+ white-space: nowrap;
956
+ overflow: hidden;
957
+ text-overflow: ellipsis;
958
+ }
959
+
960
+ .cell-error {
961
+ vertical-align: middle;
962
+ }
963
+
964
+ .error-toggle {
965
+ background: transparent;
966
+ border: none;
967
+ color: var(--accent-primary);
968
+ font-size: 11px;
969
+ padding: 0;
970
+ cursor: pointer;
971
+ white-space: nowrap;
972
+ margin-top: 3px;
973
+ font-weight: 500;
974
+ line-height: 1.2;
975
+ min-width: 32px;
976
+ text-align: left;
977
+ flex-shrink: 0;
978
+ }
979
+
980
+ .error-toggle:hover {
981
+ text-decoration: underline;
982
+ }
983
+
609
984
  /* ... existing styles ... */
610
985
 
611
986
  .list-actions .searchbox button {
@@ -641,12 +1016,14 @@ th {
641
1016
  font-size: 12px;
642
1017
  text-transform: uppercase;
643
1018
  border-bottom: 1px solid var(--border-subtle);
1019
+ vertical-align: middle;
644
1020
  }
645
1021
 
646
1022
  td {
647
- padding: 14px 20px;
1023
+ padding: 12px 20px;
648
1024
  border-bottom: 1px solid var(--border-subtle);
649
1025
  color: var(--text-main);
1026
+ vertical-align: middle;
650
1027
  }
651
1028
 
652
1029
  tr:last-child td {