codex-lb 0.2.0__py3-none-any.whl → 0.3.1__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 (44) hide show
  1. app/core/auth/__init__.py +10 -0
  2. app/core/balancer/logic.py +33 -6
  3. app/core/config/settings.py +2 -0
  4. app/core/usage/__init__.py +2 -0
  5. app/core/usage/logs.py +12 -2
  6. app/core/usage/quota.py +10 -4
  7. app/core/usage/types.py +3 -2
  8. app/db/migrations/__init__.py +14 -3
  9. app/db/migrations/versions/add_accounts_chatgpt_account_id.py +29 -0
  10. app/db/migrations/versions/add_accounts_reset_at.py +29 -0
  11. app/db/migrations/versions/add_dashboard_settings.py +31 -0
  12. app/db/migrations/versions/add_request_logs_reasoning_effort.py +21 -0
  13. app/db/models.py +33 -0
  14. app/db/session.py +71 -11
  15. app/dependencies.py +27 -1
  16. app/main.py +11 -2
  17. app/modules/accounts/auth_manager.py +44 -3
  18. app/modules/accounts/repository.py +14 -6
  19. app/modules/accounts/service.py +4 -2
  20. app/modules/oauth/service.py +4 -3
  21. app/modules/proxy/load_balancer.py +74 -5
  22. app/modules/proxy/service.py +155 -31
  23. app/modules/proxy/sticky_repository.py +56 -0
  24. app/modules/request_logs/repository.py +6 -3
  25. app/modules/request_logs/schemas.py +2 -0
  26. app/modules/request_logs/service.py +8 -1
  27. app/modules/settings/__init__.py +1 -0
  28. app/modules/settings/api.py +37 -0
  29. app/modules/settings/repository.py +40 -0
  30. app/modules/settings/schemas.py +13 -0
  31. app/modules/settings/service.py +33 -0
  32. app/modules/shared/schemas.py +16 -2
  33. app/modules/usage/schemas.py +1 -0
  34. app/modules/usage/service.py +17 -1
  35. app/modules/usage/updater.py +36 -7
  36. app/static/index.css +1024 -319
  37. app/static/index.html +461 -377
  38. app/static/index.js +327 -49
  39. {codex_lb-0.2.0.dist-info → codex_lb-0.3.1.dist-info}/METADATA +33 -7
  40. {codex_lb-0.2.0.dist-info → codex_lb-0.3.1.dist-info}/RECORD +43 -34
  41. app/static/7.css +0 -1336
  42. {codex_lb-0.2.0.dist-info → codex_lb-0.3.1.dist-info}/WHEEL +0 -0
  43. {codex_lb-0.2.0.dist-info → codex_lb-0.3.1.dist-info}/entry_points.txt +0 -0
  44. {codex_lb-0.2.0.dist-info → codex_lb-0.3.1.dist-info}/licenses/LICENSE +0 -0
app/static/index.js CHANGED
@@ -16,6 +16,7 @@
16
16
  oauthStart: "/api/oauth/start",
17
17
  oauthStatus: "/api/oauth/status",
18
18
  oauthComplete: "/api/oauth/complete",
19
+ settings: "/api/settings",
19
20
  };
20
21
 
21
22
  const PAGES = [
@@ -33,6 +34,13 @@
33
34
  title: "Codex Load Balancer - Accounts",
34
35
  path: "/accounts",
35
36
  },
37
+ {
38
+ id: "settings",
39
+ tabId: "tab-settings",
40
+ label: "Settings",
41
+ title: "Codex Load Balancer - Settings",
42
+ path: "/settings",
43
+ },
36
44
  ];
37
45
 
38
46
  const BASE_PATH = "/dashboard";
@@ -187,6 +195,7 @@
187
195
  metrics: {
188
196
  requests7d: 0,
189
197
  tokensSecondaryWindow: null,
198
+ cachedTokensSecondaryWindow: null,
190
199
  cost7d: 0,
191
200
  errorRate7d: null,
192
201
  topError: "",
@@ -242,6 +251,40 @@
242
251
  return compactFormatter.format(numeric);
243
252
  };
244
253
 
254
+ const formatTokensWithCached = (totalTokens, cachedInputTokens) => {
255
+ const total = toNumber(totalTokens);
256
+ if (total === null) {
257
+ return "--";
258
+ }
259
+ const cached = toNumber(cachedInputTokens);
260
+ if (cached === null || cached <= 0) {
261
+ return formatCompactNumber(total);
262
+ }
263
+ return `${formatCompactNumber(total)} (${formatCompactNumber(cached)} Cached)`;
264
+ };
265
+
266
+ const formatCachedTokensMeta = (totalTokens, cachedInputTokens) => {
267
+ const total = toNumber(totalTokens);
268
+ const cached = toNumber(cachedInputTokens);
269
+ if (total === null || total <= 0 || cached === null || cached <= 0) {
270
+ return "Cached: --";
271
+ }
272
+ const percent = Math.min(100, Math.max(0, (cached / total) * 100));
273
+ return `Cached: ${formatCompactNumber(cached)} (${Math.round(percent)}%)`;
274
+ };
275
+
276
+ const formatModelLabel = (model, reasoningEffort) => {
277
+ const base = (model || "").trim();
278
+ if (!base) {
279
+ return "--";
280
+ }
281
+ const effort = (reasoningEffort || "").trim();
282
+ if (!effort) {
283
+ return base;
284
+ }
285
+ return `${base} (${effort})`;
286
+ };
287
+
245
288
  const formatCurrency = (value) => {
246
289
  const numeric = toNumber(value);
247
290
  if (numeric === null) {
@@ -463,6 +506,33 @@
463
506
  };
464
507
  const routingLabel = (strategy) => ROUTING_LABELS[strategy] || "unknown";
465
508
  const errorLabel = (code) => ERROR_LABELS[code] || "--";
509
+ const calculateProgressClass = (status, remainingPercent) => {
510
+ if (status === "exceeded") return "error";
511
+ if (status === "paused" || status === "deactivated") return "";
512
+ const percent = toNumber(remainingPercent) || 0;
513
+ if (percent <= 20) return "error";
514
+ if (percent <= 50) return "limited";
515
+ return "success";
516
+ };
517
+ const calculateProgressTextClass = (status, remainingPercent) => {
518
+ const cls = calculateProgressClass(status, remainingPercent);
519
+ return cls ? `text-${cls}` : "";
520
+ };
521
+
522
+ const calculateTextUsageClass = (status, remainingPercent) => {
523
+ if (status === "exceeded") return "error";
524
+ // For text, we show usage color even if paused. Only deactivated is plain.
525
+ if (status === "deactivated") return "";
526
+ const percent = toNumber(remainingPercent) || 0;
527
+ if (percent <= 20) return "error";
528
+ if (percent <= 50) return "limited";
529
+ return "success";
530
+ };
531
+
532
+ const calculateTextUsageTextClass = (status, remainingPercent) => {
533
+ const cls = calculateTextUsageClass(status, remainingPercent);
534
+ return cls ? `text-${cls}` : "";
535
+ };
466
536
  const progressClass = (status) => PROGRESS_CLASS_BY_STATUS[status] || "";
467
537
 
468
538
  const normalizeSearchInput = (value) =>
@@ -565,8 +635,10 @@
565
635
  accountId: entry.accountId,
566
636
  requestId: entry.requestId,
567
637
  model: entry.model,
638
+ reasoningEffort: entry.reasoningEffort ?? null,
568
639
  status: entry.status,
569
640
  tokens: toNumber(entry.tokens),
641
+ cachedInputTokens: toNumber(entry.cachedInputTokens),
570
642
  cost: toNumber(entry.costUsd),
571
643
  errorCode: entry.errorCode ?? null,
572
644
  errorMessage: entry.errorMessage ?? null,
@@ -595,17 +667,21 @@
595
667
  const secondaryRemainingPercent = toNumber(
596
668
  secondaryRow?.remainingPercentAvg,
597
669
  );
670
+ const mergedSecondaryRemaining =
671
+ secondaryRemainingPercent ??
672
+ account.usage?.secondaryRemainingPercent ??
673
+ 0;
674
+ const mergedPrimaryRemaining =
675
+ primaryRemainingPercent ??
676
+ account.usage?.primaryRemainingPercent ??
677
+ 0;
678
+ const effectivePrimaryRemaining =
679
+ mergedSecondaryRemaining <= 0 ? 0 : mergedPrimaryRemaining;
598
680
  return {
599
681
  ...account,
600
682
  usage: {
601
- primaryRemainingPercent:
602
- primaryRemainingPercent ??
603
- account.usage?.primaryRemainingPercent ??
604
- 0,
605
- secondaryRemainingPercent:
606
- secondaryRemainingPercent ??
607
- account.usage?.secondaryRemainingPercent ??
608
- 0,
683
+ primaryRemainingPercent: effectivePrimaryRemaining,
684
+ secondaryRemainingPercent: mergedSecondaryRemaining,
609
685
  },
610
686
  resetAtPrimary: account.resetAtPrimary ?? null,
611
687
  resetAtSecondary: account.resetAtSecondary ?? null,
@@ -641,6 +717,7 @@
641
717
  accountId: entry.accountId,
642
718
  capacityCredits: toNumber(entry.capacityCredits) || 0,
643
719
  remainingCredits: toNumber(entry.remainingCredits) || 0,
720
+ remainingPercentAvg: toNumber(entry.remainingPercentAvg),
644
721
  })),
645
722
  };
646
723
  };
@@ -654,6 +731,7 @@
654
731
  const metrics = summary?.metrics || {};
655
732
  const requests7d = toNumber(metrics.requests7d) ?? 0;
656
733
  const tokensSecondaryWindow = toNumber(metrics.tokensSecondaryWindow);
734
+ const cachedTokensSecondaryWindow = toNumber(metrics.cachedTokensSecondaryWindow);
657
735
  const errorRate7d = toNumber(metrics.errorRate7d);
658
736
  const topError = metrics.topError || "";
659
737
  return {
@@ -665,6 +743,7 @@
665
743
  metrics: {
666
744
  requests7d,
667
745
  tokensSecondaryWindow,
746
+ cachedTokensSecondaryWindow,
668
747
  cost7d: toNumber(summary?.cost?.totalUsd7d) || 0,
669
748
  errorRate7d,
670
749
  topError,
@@ -699,7 +778,7 @@
699
778
  return acc;
700
779
  }, {});
701
780
 
702
- const buildRemainingItems = (entries, accounts, capacity) => {
781
+ const buildRemainingItems = (entries, accounts, capacity, windowKey) => {
703
782
  const accountMap = new Map(
704
783
  (accounts || []).map((account) => [account.id, account]),
705
784
  );
@@ -707,7 +786,23 @@
707
786
  const account = accountMap.get(entry.accountId);
708
787
  const label = account ? account.email : entry.accountId;
709
788
  const value = toNumber(entry.remainingCredits) || 0;
710
- const rawPercent = capacity > 0 ? (value / capacity) * 100 : 0;
789
+ const percentFromApi = toNumber(entry.remainingPercentAvg);
790
+ const percentFromAccount =
791
+ windowKey === "primary"
792
+ ? toNumber(account?.usage?.primaryRemainingPercent)
793
+ : windowKey === "secondary"
794
+ ? toNumber(account?.usage?.secondaryRemainingPercent)
795
+ : null;
796
+ const entryCapacity = toNumber(entry.capacityCredits) || 0;
797
+ const denominator = entryCapacity > 0 ? entryCapacity : capacity;
798
+ const rawPercent =
799
+ percentFromApi !== null
800
+ ? percentFromApi
801
+ : percentFromAccount !== null
802
+ ? percentFromAccount
803
+ : denominator > 0
804
+ ? (value / denominator) * 100
805
+ : 0;
711
806
  const remainingPercent = Math.min(100, Math.max(0, rawPercent));
712
807
  return {
713
808
  accountId: entry.accountId,
@@ -734,6 +829,38 @@
734
829
  }));
735
830
  };
736
831
 
832
+ const buildSecondaryExhaustedIndex = (accounts) => {
833
+ const exhausted = new Set();
834
+ (accounts || []).forEach((account) => {
835
+ const remaining = toNumber(account?.usage?.secondaryRemainingPercent);
836
+ if (remaining !== null && remaining <= 0 && account?.id) {
837
+ exhausted.add(account.id);
838
+ }
839
+ });
840
+ return exhausted;
841
+ };
842
+
843
+ const applySecondaryExhaustedToPrimary = (entries, exhaustedIds) => {
844
+ if (!entries?.length || !exhaustedIds?.size) {
845
+ return entries || [];
846
+ }
847
+ return entries.map((entry) => {
848
+ if (entry?.accountId && exhaustedIds.has(entry.accountId)) {
849
+ return {
850
+ ...entry,
851
+ remainingCredits: 0,
852
+ };
853
+ }
854
+ return entry;
855
+ });
856
+ };
857
+
858
+ const sumRemainingCredits = (entries) =>
859
+ (entries || []).reduce(
860
+ (acc, entry) => acc + (toNumber(entry?.remainingCredits) || 0),
861
+ 0,
862
+ );
863
+
737
864
  const buildDonutGradient = (items, total) => {
738
865
  if (!items.length || total <= 0) {
739
866
  return `conic-gradient(${CONSUMED_COLOR} 0 100%)`;
@@ -776,6 +903,7 @@
776
903
  const statusCounts = countByStatus(accounts);
777
904
  const secondaryWindowMinutes =
778
905
  state.dashboardData.usage?.secondary?.windowMinutes ?? null;
906
+ const secondaryExhaustedAccounts = buildSecondaryExhaustedIndex(accounts);
779
907
 
780
908
  const badges = ["active", "paused", "limited", "exceeded", "deactivated"]
781
909
  .map((status) => {
@@ -795,7 +923,10 @@
795
923
  {
796
924
  title: `Tokens (${formatWindowLabel("secondary", secondaryWindowMinutes)})`,
797
925
  value: formatCompactNumber(metrics.tokensSecondaryWindow),
798
- meta: "Scope: responses",
926
+ meta: formatCachedTokensMeta(
927
+ metrics.tokensSecondaryWindow,
928
+ metrics.cachedTokensSecondaryWindow,
929
+ ),
799
930
  },
800
931
  {
801
932
  title: "Cost (7d)",
@@ -823,28 +954,67 @@
823
954
  resetAt: null,
824
955
  byAccount: [],
825
956
  };
826
- const remaining = toNumber(usage.remaining) || 0;
957
+ const rawEntries = usage.byAccount || [];
958
+ const hasPrimaryAdjustments =
959
+ window.key === "primary" &&
960
+ rawEntries.some(
961
+ (entry) =>
962
+ entry?.accountId && secondaryExhaustedAccounts.has(entry.accountId),
963
+ );
964
+ const entries =
965
+ window.key === "primary"
966
+ ? applySecondaryExhaustedToPrimary(
967
+ rawEntries,
968
+ secondaryExhaustedAccounts,
969
+ )
970
+ : rawEntries;
971
+ const remaining =
972
+ hasPrimaryAdjustments
973
+ ? sumRemainingCredits(entries)
974
+ : toNumber(usage.remaining) || 0;
827
975
  const capacity = Math.max(remaining, toNumber(usage.capacity) || 0);
828
976
  const consumed = Math.max(0, capacity - remaining);
829
977
  const items = buildRemainingItems(
830
- usage.byAccount || [],
978
+ entries,
831
979
  accounts,
832
980
  capacity,
981
+ window.key,
833
982
  );
834
983
  const gradient = buildDonutGradient(items, capacity);
835
- const legendItems = items.map((item) => ({
836
- label: item.label,
837
- detail: `Remaining ${formatPercent(item.remainingPercent)}`,
838
- color: item.color,
839
- }));
840
- if (capacity > 0 && consumed > 0) {
984
+ const legendItems = items.map((item) => {
985
+ const percent = item.remainingPercent;
986
+ let valueClass = "success";
987
+ if (percent <= 20) {
988
+ valueClass = "error";
989
+ } else if (percent <= 50) {
990
+ valueClass = "limited";
991
+ }
992
+ return {
993
+ label: truncateText(item.label, 28),
994
+ fullLabel: item.label,
995
+ detailLabel: "Remaining",
996
+ detailValue: formatPercent(item.remainingPercent),
997
+ valueClass,
998
+ color: item.color,
999
+ };
1000
+ });
1001
+ if (capacity > 0) {
841
1002
  const consumedPercent = Math.min(
842
1003
  100,
843
1004
  Math.max(0, (consumed / capacity) * 100),
844
1005
  );
1006
+ let consumedClass = "success";
1007
+ if (consumedPercent >= 80) {
1008
+ consumedClass = "error";
1009
+ } else if (consumedPercent >= 50) {
1010
+ consumedClass = "limited";
1011
+ }
845
1012
  legendItems.push({
846
1013
  label: "Consumed",
847
- detail: `${formatPercent(consumedPercent)}`,
1014
+ fullLabel: "Consumed",
1015
+ detailLabel: "",
1016
+ detailValue: formatPercent(consumedPercent),
1017
+ valueClass: consumedClass,
848
1018
  color: CONSUMED_COLOR,
849
1019
  });
850
1020
  }
@@ -871,7 +1041,7 @@
871
1041
  },
872
1042
  remaining: remainingRounded,
873
1043
  remainingText: formatPercent(secondaryRemaining),
874
- progressClass: progressClass(account.status),
1044
+ progressClass: calculateProgressClass(account.status, secondaryRemaining),
875
1045
  marquee: account.status === "deactivated",
876
1046
  meta: formatQuotaResetMeta(
877
1047
  account.resetAtSecondary,
@@ -884,17 +1054,18 @@
884
1054
  const requests = state.dashboardData.recentRequests.map((request) => {
885
1055
  const rawError = request.errorMessage || request.errorCode || "";
886
1056
  const accountLabel = formatAccountLabel(request.accountId, accounts);
1057
+ const modelLabel = formatModelLabel(request.model, request.reasoningEffort);
887
1058
  return {
888
1059
  key: `${request.requestId}-${request.timestamp}`,
889
1060
  requestId: request.requestId,
890
1061
  time: formatTimeLong(request.timestamp),
891
1062
  account: accountLabel,
892
- model: request.model,
1063
+ model: modelLabel,
893
1064
  status: {
894
1065
  class: requestStatusClass(request.status),
895
1066
  label: requestStatusLabel(request.status),
896
1067
  },
897
- tokens: formatNumber(request.tokens),
1068
+ tokens: formatTokensWithCached(request.tokens, request.cachedInputTokens),
898
1069
  cost: formatCurrency(request.cost),
899
1070
  error: rawError ? truncateText(rawError, 80) : "--",
900
1071
  errorTitle: rawError,
@@ -1031,6 +1202,20 @@
1031
1202
  return responsePayload;
1032
1203
  };
1033
1204
 
1205
+ const putJson = async (url, payload, label) => {
1206
+ const response = await fetch(url, {
1207
+ method: "PUT",
1208
+ headers: { "Content-Type": "application/json" },
1209
+ body: JSON.stringify(payload || {}),
1210
+ });
1211
+ const responsePayload = await readResponsePayload(response);
1212
+ if (!response.ok) {
1213
+ const message = extractErrorMessage(responsePayload);
1214
+ throw new Error(message || `Failed to ${label} (${response.status})`);
1215
+ }
1216
+ return responsePayload;
1217
+ };
1218
+
1034
1219
  const deleteJson = async (url, label) => {
1035
1220
  const response = await fetch(url, { method: "DELETE" });
1036
1221
  const responsePayload = await readResponsePayload(response);
@@ -1055,6 +1240,16 @@
1055
1240
  const fetchRequestLogs = async (limit) =>
1056
1241
  fetchJson(API_ENDPOINTS.requestLogs(limit), "request logs");
1057
1242
 
1243
+ const normalizeSettingsPayload = (payload) => ({
1244
+ stickyThreadsEnabled: Boolean(payload?.stickyThreadsEnabled),
1245
+ preferEarlierResetAccounts: Boolean(payload?.preferEarlierResetAccounts),
1246
+ });
1247
+
1248
+ const fetchSettings = async () => {
1249
+ const payload = await fetchJson(API_ENDPOINTS.settings, "settings");
1250
+ return normalizeSettingsPayload(payload);
1251
+ };
1252
+
1058
1253
  const registerApp = () => {
1059
1254
  Alpine.data("feApp", () => ({
1060
1255
  view: "dashboard",
@@ -1063,6 +1258,11 @@
1063
1258
  ui: createUiConfig(),
1064
1259
  dashboardData: createEmptyDashboardData(),
1065
1260
  dashboard: createEmptyDashboardView(),
1261
+ settings: {
1262
+ stickyThreadsEnabled: false,
1263
+ preferEarlierResetAccounts: false,
1264
+ isSaving: false,
1265
+ },
1066
1266
  accounts: {
1067
1267
  selectedId: "",
1068
1268
  rows: [],
@@ -1158,12 +1358,14 @@
1158
1358
  primaryResult,
1159
1359
  secondaryResult,
1160
1360
  requestLogsResult,
1361
+ settingsResult,
1161
1362
  ] = await Promise.allSettled([
1162
1363
  fetchAccounts(),
1163
1364
  fetchUsageSummary(),
1164
1365
  fetchUsageWindow("primary"),
1165
1366
  fetchUsageWindow("secondary"),
1166
1367
  fetchRequestLogs(50),
1368
+ fetchSettings(),
1167
1369
  ]);
1168
1370
  const errors = [];
1169
1371
  if (accountsResult.status !== "fulfilled") {
@@ -1196,6 +1398,12 @@
1196
1398
  errors.push(requestLogsResult.reason);
1197
1399
  }
1198
1400
 
1401
+ const settings =
1402
+ settingsResult.status === "fulfilled" ? settingsResult.value : null;
1403
+ if (settingsResult.status === "rejected") {
1404
+ errors.push(settingsResult.reason);
1405
+ }
1406
+
1199
1407
  const mergedAccounts = mergeUsageIntoAccounts(
1200
1408
  accountsResult.value,
1201
1409
  primaryUsage,
@@ -1208,6 +1416,7 @@
1208
1416
  primaryUsage,
1209
1417
  secondaryUsage,
1210
1418
  requestLogs,
1419
+ settings,
1211
1420
  },
1212
1421
  preferredId,
1213
1422
  );
@@ -1252,11 +1461,54 @@
1252
1461
  primaryUsage: data.primaryUsage,
1253
1462
  secondaryUsage: data.secondaryUsage,
1254
1463
  requestLogs: data.requestLogs,
1464
+ settings: data.settings,
1255
1465
  });
1466
+ if (data.settings) {
1467
+ this.settings.stickyThreadsEnabled = Boolean(
1468
+ data.settings.stickyThreadsEnabled,
1469
+ );
1470
+ this.settings.preferEarlierResetAccounts = Boolean(
1471
+ data.settings.preferEarlierResetAccounts,
1472
+ );
1473
+ }
1256
1474
  this.ui.usageWindows = buildUsageWindowConfig(data.summary);
1257
1475
  this.dashboard = buildDashboardView(this);
1258
1476
  this.syncAccountSearchSelection();
1259
1477
  },
1478
+ async saveSettings() {
1479
+ if (this.settings.isSaving) {
1480
+ return;
1481
+ }
1482
+ this.settings.isSaving = true;
1483
+ try {
1484
+ const payload = {
1485
+ stickyThreadsEnabled: this.settings.stickyThreadsEnabled,
1486
+ preferEarlierResetAccounts: this.settings.preferEarlierResetAccounts,
1487
+ };
1488
+ const updated = await putJson(
1489
+ API_ENDPOINTS.settings,
1490
+ payload,
1491
+ "save settings",
1492
+ );
1493
+ const normalized = normalizeSettingsPayload(updated);
1494
+ this.settings.stickyThreadsEnabled = normalized.stickyThreadsEnabled;
1495
+ this.settings.preferEarlierResetAccounts =
1496
+ normalized.preferEarlierResetAccounts;
1497
+ this.openMessageBox({
1498
+ tone: "success",
1499
+ title: "Settings saved",
1500
+ message: "Routing settings updated.",
1501
+ });
1502
+ } catch (error) {
1503
+ this.openMessageBox({
1504
+ tone: "error",
1505
+ title: "Settings save failed",
1506
+ message: error.message || "Failed to save settings.",
1507
+ });
1508
+ } finally {
1509
+ this.settings.isSaving = false;
1510
+ }
1511
+ },
1260
1512
  focusAccountSearch() {
1261
1513
  this.$refs.accountSearch?.focus();
1262
1514
  },
@@ -1494,12 +1746,33 @@
1494
1746
  window.open(this.authDialog.verificationUrl, "_blank", "noopener");
1495
1747
  }
1496
1748
  },
1497
- async copyToClipboard(value, label) {
1498
- if (!value) {
1499
- return;
1749
+ calculateProgressClass(status, remainingPercent) {
1750
+ return calculateProgressClass(status, remainingPercent);
1751
+ },
1752
+ calculateProgressTextClass(status, remainingPercent) {
1753
+ return calculateProgressTextClass(status, remainingPercent);
1754
+ },
1755
+ async copyToClipboard(value, label, e) {
1756
+ if (!value) return;
1757
+
1758
+ // Localized feedback in the button
1759
+ let btn = null;
1760
+ let originalText = "";
1761
+ if (e && e.target) {
1762
+ btn = e.target.tagName === "BUTTON" ? e.target : e.target.closest("button");
1763
+ if (btn) {
1764
+ originalText = btn.textContent;
1765
+ btn.textContent = "Copied!";
1766
+ btn.classList.add("copy-success");
1767
+ window.setTimeout(() => {
1768
+ btn.textContent = originalText;
1769
+ btn.classList.remove("copy-success");
1770
+ }, 4000);
1771
+ }
1500
1772
  }
1773
+
1501
1774
  try {
1502
- if (navigator.clipboard?.writeText) {
1775
+ if (navigator.clipboard && navigator.clipboard.writeText) {
1503
1776
  await navigator.clipboard.writeText(value);
1504
1777
  } else {
1505
1778
  const textarea = document.createElement("textarea");
@@ -1512,29 +1785,26 @@
1512
1785
  document.execCommand("copy");
1513
1786
  document.body.removeChild(textarea);
1514
1787
  }
1515
- if (this.authDialog.open) {
1516
- const previousLabel = this.authDialog.statusLabel;
1517
- this.authDialog.statusLabel = `${label} copied.`;
1518
- window.setTimeout(() => {
1519
- if (this.authDialog.statusLabel === `${label} copied.`) {
1520
- this.authDialog.statusLabel = previousLabel;
1521
- }
1522
- }, 2000);
1523
- } else {
1788
+
1789
+ // Only show message box if auth dialog is not open and button feedback wasn't possible
1790
+ if (!this.authDialog.open && !btn) {
1524
1791
  this.openMessageBox({
1525
1792
  tone: "success",
1526
1793
  title: "Copied",
1527
1794
  message: `${label} copied to clipboard.`,
1528
1795
  });
1529
1796
  }
1530
- } catch (error) {
1531
- if (this.authDialog.open) {
1532
- this.authDialog.statusLabel = "Copy failed.";
1533
- } else {
1797
+ } catch (err) {
1798
+ console.error("Clipboard error:", err);
1799
+ if (btn) {
1800
+ btn.textContent = "Failed";
1801
+ btn.classList.remove("copy-success");
1802
+ window.setTimeout(() => { btn.textContent = originalText; }, 2000);
1803
+ } else if (!this.authDialog.open) {
1534
1804
  this.openMessageBox({
1535
1805
  tone: "error",
1536
1806
  title: "Copy failed",
1537
- message: "Unable to copy to clipboard.",
1807
+ message: `Could not copy ${label}.`,
1538
1808
  });
1539
1809
  }
1540
1810
  }
@@ -1693,15 +1963,15 @@
1693
1963
  const items =
1694
1964
  this.view === "accounts"
1695
1965
  ? [
1696
- `Selection: ${this.accounts.selectedId || "--"}`,
1697
- `Rotation: ${this.dashboardData.routing?.rotationEnabled ? "enabled" : "disabled"}`,
1698
- `Last sync: ${lastSync}`,
1699
- ]
1966
+ `Selection: ${this.accounts.selectedId || "--"}`,
1967
+ `Rotation: ${this.dashboardData.routing?.rotationEnabled ? "enabled" : "disabled"}`,
1968
+ `Last sync: ${lastSync}`,
1969
+ ]
1700
1970
  : [
1701
- `Last sync: ${lastSync}`,
1702
- `Routing: ${routingLabel(this.dashboardData.routing?.strategy)}`,
1703
- `Backend: ${this.backendPath}`,
1704
- ];
1971
+ `Last sync: ${lastSync}`,
1972
+ `Routing: ${routingLabel(this.dashboardData.routing?.strategy)}`,
1973
+ `Backend: ${this.backendPath}`,
1974
+ ];
1705
1975
  if (this.importState.isLoading) {
1706
1976
  items.unshift(
1707
1977
  `Importing ${this.importState.fileName || "auth.json"}...`,
@@ -1878,6 +2148,7 @@
1878
2148
  statusLabel,
1879
2149
  requestStatusLabel,
1880
2150
  requestStatusClass,
2151
+ calculateTextUsageTextClass,
1881
2152
  progressClass,
1882
2153
  planLabel,
1883
2154
  routingLabel,
@@ -1894,7 +2165,14 @@
1894
2165
  formatQuotaResetLabel,
1895
2166
  formatAccessTokenLabel,
1896
2167
  formatRefreshTokenLabel,
2168
+ formatAccessTokenLabel,
2169
+ formatRefreshTokenLabel,
1897
2170
  formatIdTokenLabel,
2171
+ theme: localStorage.getItem('theme') || 'dark',
2172
+ toggleTheme() {
2173
+ this.theme = this.theme === 'dark' ? 'light' : 'dark';
2174
+ localStorage.setItem('theme', this.theme);
2175
+ },
1898
2176
  }));
1899
2177
  };
1900
2178
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-lb
3
- Version: 0.2.0
3
+ Version: 0.3.1
4
4
  Summary: Codex load balancer and proxy for ChatGPT accounts with usage dashboard
5
5
  Author-email: Soju06 <qlskssk@gmail.com>
6
6
  Maintainer-email: Soju06 <qlskssk@gmail.com>
@@ -39,7 +39,7 @@ Classifier: Topic :: Software Development :: Libraries
39
39
  Classifier: Topic :: System :: Networking
40
40
  Requires-Python: >=3.13
41
41
  Requires-Dist: aiohttp-retry>=2.9.1
42
- Requires-Dist: aiohttp>=3.13.2
42
+ Requires-Dist: aiohttp>=3.13.3
43
43
  Requires-Dist: aiosqlite>=0.22.1
44
44
  Requires-Dist: cryptography>=46.0.3
45
45
  Requires-Dist: fastapi[standard]>=0.128.0
@@ -55,9 +55,13 @@ Description-Content-Type: text/markdown
55
55
 
56
56
  Load balancer for ChatGPT accounts. Pool multiple accounts, track usage, view everything in a dashboard.
57
57
 
58
- <p align="center">
59
- <img src="https://raw.githubusercontent.com/Soju06/codex-lb/main/docs/screenshots/dashboard.jpeg" alt="Codex Load Balancer dashboard" width="100%">
60
- </p>
58
+ ### Main Dashboard View
59
+
60
+ ![main dashboard view](docs/screenshots/dashboard.jpg)
61
+
62
+ ### Accounts View
63
+
64
+ ![Accounts list and details](docs/screenshots/accounts.jpg)
61
65
 
62
66
  ## Quick Start
63
67
 
@@ -78,9 +82,7 @@ uvx codex-lb
78
82
 
79
83
  Open [localhost:2455](http://localhost:2455) → Add account → Done.
80
84
 
81
- ## Accounts view
82
85
 
83
- ![Accounts list and details](https://raw.githubusercontent.com/Soju06/codex-lb/main/docs/screenshots/accounts.jpeg)
84
86
 
85
87
  ## Codex CLI & Extension Setup
86
88
 
@@ -102,7 +104,31 @@ requires_openai_auth = true # Required: enables model selection in Codex IDE ex
102
104
  ## Data
103
105
 
104
106
  All data stored in `~/.codex-lb/`:
107
+
105
108
  - `store.db` – accounts, usage logs
106
109
  - `encryption.key` – encrypts tokens (auto-generated)
107
110
 
108
111
  Backup this directory to preserve your accounts.
112
+
113
+ ## Contributors ✨
114
+
115
+ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
116
+ <!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
117
+ <!-- prettier-ignore-start -->
118
+ <!-- markdownlint-disable -->
119
+ <table>
120
+ <tbody>
121
+ <tr>
122
+ <td align="center" valign="top" width="14.28%"><a href="https://github.com/Soju06"><img src="https://avatars.githubusercontent.com/u/34199905?v=4?s=100" width="100px;" alt="Soju06"/><br /><sub><b>Soju06</b></sub></a><br /><a href="https://github.com/Soju06/codex-lb/commits?author=Soju06" title="Code">💻</a> <a href="https://github.com/Soju06/codex-lb/commits?author=Soju06" title="Tests">⚠️</a> <a href="#maintenance-Soju06" title="Maintenance">🚧</a> <a href="#infra-Soju06" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
123
+ <td align="center" valign="top" width="14.28%"><a href="http://jonas.kamsker.at/"><img src="https://avatars.githubusercontent.com/u/11245306?v=4?s=100" width="100px;" alt="Jonas Kamsker"/><br /><sub><b>Jonas Kamsker</b></sub></a><br /><a href="https://github.com/Soju06/codex-lb/commits?author=JKamsker" title="Code">💻</a> <a href="https://github.com/Soju06/codex-lb/issues?q=author%3AJKamsker" title="Bug reports">🐛</a> <a href="#maintenance-JKamsker" title="Maintenance">🚧</a></td>
124
+ <td align="center" valign="top" width="14.28%"><a href="https://github.com/Quack6765"><img src="https://avatars.githubusercontent.com/u/5446230?v=4?s=100" width="100px;" alt="Quack"/><br /><sub><b>Quack</b></sub></a><br /><a href="https://github.com/Soju06/codex-lb/commits?author=Quack6765" title="Code">💻</a> <a href="https://github.com/Soju06/codex-lb/issues?q=author%3AQuack6765" title="Bug reports">🐛</a> <a href="#maintenance-Quack6765" title="Maintenance">🚧</a> <a href="#design-Quack6765" title="Design">🎨</a></td>
125
+ </tr>
126
+ </tbody>
127
+ </table>
128
+
129
+ <!-- markdownlint-restore -->
130
+ <!-- prettier-ignore-end -->
131
+
132
+ <!-- ALL-CONTRIBUTORS-LIST:END -->
133
+
134
+ This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!