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.
- app/core/auth/__init__.py +10 -0
- app/core/balancer/logic.py +33 -6
- app/core/config/settings.py +2 -0
- app/core/usage/__init__.py +2 -0
- app/core/usage/logs.py +12 -2
- app/core/usage/quota.py +10 -4
- app/core/usage/types.py +3 -2
- app/db/migrations/__init__.py +14 -3
- 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/models.py +33 -0
- app/db/session.py +71 -11
- app/dependencies.py +27 -1
- app/main.py +11 -2
- app/modules/accounts/auth_manager.py +44 -3
- app/modules/accounts/repository.py +14 -6
- app/modules/accounts/service.py +4 -2
- app/modules/oauth/service.py +4 -3
- app/modules/proxy/load_balancer.py +74 -5
- app/modules/proxy/service.py +155 -31
- 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 +8 -1
- 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 +17 -1
- app/modules/usage/updater.py +36 -7
- app/static/index.css +1024 -319
- app/static/index.html +461 -377
- app/static/index.js +327 -49
- {codex_lb-0.2.0.dist-info → codex_lb-0.3.1.dist-info}/METADATA +33 -7
- {codex_lb-0.2.0.dist-info → codex_lb-0.3.1.dist-info}/RECORD +43 -34
- app/static/7.css +0 -1336
- {codex_lb-0.2.0.dist-info → codex_lb-0.3.1.dist-info}/WHEEL +0 -0
- {codex_lb-0.2.0.dist-info → codex_lb-0.3.1.dist-info}/entry_points.txt +0 -0
- {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
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
1063
|
+
model: modelLabel,
|
|
893
1064
|
status: {
|
|
894
1065
|
class: requestStatusClass(request.status),
|
|
895
1066
|
label: requestStatusLabel(request.status),
|
|
896
1067
|
},
|
|
897
|
-
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
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
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
|
|
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
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
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 (
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
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:
|
|
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
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1966
|
+
`Selection: ${this.accounts.selectedId || "--"}`,
|
|
1967
|
+
`Rotation: ${this.dashboardData.routing?.rotationEnabled ? "enabled" : "disabled"}`,
|
|
1968
|
+
`Last sync: ${lastSync}`,
|
|
1969
|
+
]
|
|
1700
1970
|
: [
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
### Main Dashboard View
|
|
59
|
+
|
|
60
|
+

|
|
61
|
+
|
|
62
|
+
### Accounts View
|
|
63
|
+
|
|
64
|
+

|
|
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
|
-

|
|
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!
|