@oharness/server 0.9.0 → 0.9.2
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.
- package/dist/account-usage.d.ts +29 -0
- package/dist/account-usage.js +56 -0
- package/dist/account-usage.js.map +1 -0
- package/dist/i18n.js +66 -2
- package/dist/i18n.js.map +1 -1
- package/dist/page/client/panels.js +5 -1
- package/dist/page/client/panels.js.map +1 -1
- package/dist/page/client/shell.js +2 -0
- package/dist/page/client/shell.js.map +1 -1
- package/dist/page/client/usage.js +450 -88
- package/dist/page/client/usage.js.map +1 -1
- package/dist/page/style.js +43 -0
- package/dist/page/style.js.map +1 -1
- package/dist/page.js +1 -0
- package/dist/page.js.map +1 -1
- package/dist/server.js +94 -35
- package/dist/server.js.map +1 -1
- package/package.json +2 -2
|
@@ -597,7 +597,11 @@ function dateRangePicker(options) {
|
|
|
597
597
|
* The models drawn are the ones with a share worth a band; the rest are
|
|
598
598
|
* summed into one so the columns still add up to the total above them.
|
|
599
599
|
*/
|
|
600
|
-
function usageChart(body, models) {
|
|
600
|
+
function usageChart(body, models, opts) {
|
|
601
|
+
// How a column is read out, and what its date is: dollars by the day for
|
|
602
|
+
// this machine's transcripts, credits by the day or hour for the account.
|
|
603
|
+
const money = (opts && opts.money) || ((value) => "$" + value.toFixed(2));
|
|
604
|
+
const when = (opts && opts.when) || ((at) => new Date(at).toLocaleDateString());
|
|
601
605
|
const series = body.series || {};
|
|
602
606
|
const buckets = series.buckets || [];
|
|
603
607
|
const card = el("div", "row-card chart-card");
|
|
@@ -683,9 +687,10 @@ function usageChart(body, models) {
|
|
|
683
687
|
// ends of the window. Rendered as text beside the plot rather than as axis
|
|
684
688
|
// ticks, which at this size would be four numbers fighting for two pixels.
|
|
685
689
|
const scale = el("div", "chart-scale");
|
|
686
|
-
const reading = el("span", null,
|
|
690
|
+
const reading = el("span", null, money(peak));
|
|
687
691
|
scale.appendChild(reading);
|
|
688
|
-
scale.appendChild(el("span", "muted",
|
|
692
|
+
scale.appendChild(el("span", "muted",
|
|
693
|
+
t(series.step === "week" ? "usage.perWeek" : series.step === "hour" ? "usage.perHour" : "usage.perDay")));
|
|
689
694
|
card.appendChild(scale);
|
|
690
695
|
|
|
691
696
|
/*
|
|
@@ -710,21 +715,24 @@ function usageChart(body, models) {
|
|
|
710
715
|
const top = lines
|
|
711
716
|
.map((line) => ({ model: line.model, cost: line.points[at] || 0 }))
|
|
712
717
|
.sort((a, b) => b.cost - a.cost)[0];
|
|
713
|
-
const on =
|
|
718
|
+
const on = when(bucket.at);
|
|
714
719
|
reading.textContent = bucket.costUsd
|
|
715
|
-
? on + " ·
|
|
720
|
+
? on + " · " + money(bucket.costUsd) + (top && top.cost ? " · " + top.model : "")
|
|
716
721
|
: on + " · " + t("usage.nothing");
|
|
717
722
|
});
|
|
718
723
|
svg.addEventListener("mouseleave", () => {
|
|
719
724
|
highlight.setAttribute("opacity", "0");
|
|
720
|
-
reading.textContent =
|
|
725
|
+
reading.textContent = money(peak);
|
|
721
726
|
});
|
|
722
727
|
|
|
723
728
|
const span = el("div", "chart-span muted small");
|
|
724
|
-
span.appendChild(el("span", null,
|
|
725
|
-
span.appendChild(el("span", null,
|
|
729
|
+
span.appendChild(el("span", null, when(buckets[0].at)));
|
|
730
|
+
span.appendChild(el("span", null, when(buckets[buckets.length - 1].at)));
|
|
726
731
|
card.appendChild(span);
|
|
727
732
|
|
|
733
|
+
// One band is its own caption: the model is already named in the list
|
|
734
|
+
// under the chart and in the readout above, and a legend of one is noise.
|
|
735
|
+
if (lines.length < 2) return card;
|
|
728
736
|
const legend = el("div", "chart-legend");
|
|
729
737
|
lines.forEach((line, index) => {
|
|
730
738
|
const entry = el("span", "legend-item");
|
|
@@ -736,18 +744,34 @@ function usageChart(body, models) {
|
|
|
736
744
|
return card;
|
|
737
745
|
}
|
|
738
746
|
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
747
|
+
/**
|
|
748
|
+
* Whose figures the page shows: the account's ledger, or this machine's
|
|
749
|
+
* transcripts. Null until someone picks, which means the account when there
|
|
750
|
+
* is one — that is where every charge is, and this machine's transcripts are
|
|
751
|
+
* only the chat that happened on it.
|
|
752
|
+
*/
|
|
753
|
+
let usageSource = null;
|
|
754
|
+
/** Whether the last look found an account, so this machine's view can offer the other. */
|
|
755
|
+
let usageSignedIn = false;
|
|
756
|
+
/** Which kind of spend the account view is narrowed to. */
|
|
757
|
+
let usageKind = "all";
|
|
758
|
+
const USAGE_KINDS = ["all", "text", "image", "video", "audio"];
|
|
749
759
|
|
|
750
|
-
|
|
760
|
+
/**
|
|
761
|
+
* The window being asked about, as a query on path.
|
|
762
|
+
*
|
|
763
|
+
* The days ride along only when they are the window being asked for; the
|
|
764
|
+
* server ignores them otherwise, and a preset request that carried a stale
|
|
765
|
+
* pair would be one keystroke from answering for the wrong fortnight.
|
|
766
|
+
*/
|
|
767
|
+
function usageQuery(path) {
|
|
768
|
+
return usageRange === "custom" && usageFrom && usageTo
|
|
769
|
+
? path + "?range=custom&from=" + encodeURIComponent(usageFrom) + "&to=" + encodeURIComponent(usageTo)
|
|
770
|
+
: path + "?range=" + encodeURIComponent(usageRange);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/** The page's heading, its way back, and — for an account — whose figures these are. */
|
|
774
|
+
function usageHead(panel) {
|
|
751
775
|
const head = el("div", "row-head");
|
|
752
776
|
head.appendChild(el("h2", null, t("usage.title")));
|
|
753
777
|
// Back to where this was opened from, because it was opened from a row in
|
|
@@ -759,29 +783,45 @@ async function showUsage() {
|
|
|
759
783
|
setNav("nav-settings");
|
|
760
784
|
void showSettings();
|
|
761
785
|
});
|
|
762
|
-
head.appendChild(back);
|
|
763
|
-
panel.appendChild(head);
|
|
764
|
-
|
|
765
786
|
/*
|
|
766
|
-
*
|
|
787
|
+
* Whose figures these are, as a small switch in the heading rather than a
|
|
788
|
+
* third row of tabs.
|
|
767
789
|
*
|
|
768
|
-
*
|
|
769
|
-
* are
|
|
770
|
-
*
|
|
771
|
-
* filtering what is already here — the totals, the models and the sessions
|
|
772
|
-
* all answer for the window, and a page where the chart moved and the
|
|
773
|
-
* figures above it did not would be lying about one of them.
|
|
790
|
+
* The account is the answer almost every visit wants; this machine's
|
|
791
|
+
* transcripts are the second look, for chat on a key someone brought. A
|
|
792
|
+
* switch as loud as the window and the kind would ask to be read first.
|
|
774
793
|
*/
|
|
794
|
+
const actions = el("span", "usage-head-actions");
|
|
795
|
+
if (usageSignedIn) {
|
|
796
|
+
const chosen = usageSource || "account";
|
|
797
|
+
const box = el("div", "seg usage-source");
|
|
798
|
+
for (const id of ["account", "device"]) {
|
|
799
|
+
const button = el("button", "seg-item" + (id === chosen ? " on" : ""), t("usage.source." + id));
|
|
800
|
+
button.type = "button";
|
|
801
|
+
button.addEventListener("click", () => {
|
|
802
|
+
if (id === chosen) return;
|
|
803
|
+
usageSource = id;
|
|
804
|
+
void showUsage();
|
|
805
|
+
});
|
|
806
|
+
box.appendChild(button);
|
|
807
|
+
}
|
|
808
|
+
actions.appendChild(box);
|
|
809
|
+
}
|
|
810
|
+
actions.appendChild(back);
|
|
811
|
+
head.appendChild(actions);
|
|
812
|
+
panel.appendChild(head);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/** The window presets and the calendar, reloading the page on a change. */
|
|
816
|
+
function usageWindowRow(body, presets) {
|
|
775
817
|
const picked = body.range || "30d";
|
|
776
818
|
// What the server says the window is, which is not always what was asked
|
|
777
819
|
// for: a picked range is clamped to today and to a year, and the calendar
|
|
778
820
|
// has to open on the days the figures beside it are actually counting.
|
|
779
821
|
const today = new Date().toISOString().slice(0, 10);
|
|
780
|
-
const
|
|
781
|
-
const shownTo = body.to || today;
|
|
782
|
-
const windowRow = el("div", "usage-window");
|
|
822
|
+
const row = el("div", "usage-window");
|
|
783
823
|
const ranges = el("div", "seg");
|
|
784
|
-
for (const id of
|
|
824
|
+
for (const id of presets) {
|
|
785
825
|
const button = el("button", "seg-item" + (id === picked ? " on" : ""), t("usage.range." + id));
|
|
786
826
|
button.type = "button";
|
|
787
827
|
button.addEventListener("click", () => {
|
|
@@ -791,20 +831,10 @@ async function showUsage() {
|
|
|
791
831
|
});
|
|
792
832
|
ranges.appendChild(button);
|
|
793
833
|
}
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
* Beside them rather than as a fifth item on the same control: the presets
|
|
799
|
-
* are one click each and this one opens a calendar, and a segmented control
|
|
800
|
-
* whose last segment behaves differently from the other four is a control
|
|
801
|
-
* that has to be learned. It shows the days it is showing once it is the
|
|
802
|
-
* chosen window, so the page never has a range on it that nothing on screen
|
|
803
|
-
* names.
|
|
804
|
-
*/
|
|
805
|
-
windowRow.appendChild(dateRangePicker({
|
|
806
|
-
from: shownFrom,
|
|
807
|
-
to: shownTo,
|
|
834
|
+
row.appendChild(ranges);
|
|
835
|
+
row.appendChild(dateRangePicker({
|
|
836
|
+
from: body.from || today,
|
|
837
|
+
to: body.to || today,
|
|
808
838
|
max: today,
|
|
809
839
|
maxDays: MAX_CUSTOM_DAYS,
|
|
810
840
|
active: picked === "custom",
|
|
@@ -815,7 +845,374 @@ async function showUsage() {
|
|
|
815
845
|
void showUsage();
|
|
816
846
|
},
|
|
817
847
|
}));
|
|
818
|
-
|
|
848
|
+
return row;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/** Credits as a bill reads them: at most two decimals, and no trailing zeros. */
|
|
852
|
+
const usageCredits = (micro) => t("usage.credits", { n: creditsOf(micro) });
|
|
853
|
+
const usageCalls = (n) => (n === 1 ? t("usage.callOne") : t("usage.calls", { n: n.toLocaleString(LOCALE) }));
|
|
854
|
+
const usageSeconds = (n) => t("usage.seconds", { n: Math.round(n).toLocaleString(LOCALE) });
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* The account's spend: chat from every device, and every image, video and
|
|
858
|
+
* clip of audio, from the same ledger the site's usage page reads.
|
|
859
|
+
*
|
|
860
|
+
* Split by kind the way the site splits it, because a credit spent on a video
|
|
861
|
+
* and one spent on a chat are the same money bought very differently — and
|
|
862
|
+
* the question someone opening this after a week of generating is asking is
|
|
863
|
+
* "what did the pictures cost", which a list of forty models does not answer.
|
|
864
|
+
*/
|
|
865
|
+
function showAccountUsage(body) {
|
|
866
|
+
panelBody.replaceChildren();
|
|
867
|
+
const panel = el("div", "panel usage");
|
|
868
|
+
usageHead(panel);
|
|
869
|
+
|
|
870
|
+
if (body.unavailable) {
|
|
871
|
+
panel.appendChild(el("p", "muted",
|
|
872
|
+
t(body.unavailable === "outdated" ? "usage.accountOutdated" : "usage.accountUnreachable")));
|
|
873
|
+
if (body.error) panel.appendChild(el("p", "muted small", body.error));
|
|
874
|
+
panelBody.appendChild(panel);
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
panel.appendChild(usageWindowRow(body, ["24h", "7d", "30d", "90d"]));
|
|
879
|
+
const summary = body.summary || {};
|
|
880
|
+
const byKind = summary.byKind || {};
|
|
881
|
+
const kind = body.kind || "all";
|
|
882
|
+
|
|
883
|
+
/*
|
|
884
|
+
* The kinds, as the figures themselves.
|
|
885
|
+
*
|
|
886
|
+
* Each card is what that kind cost and how often it ran, and choosing one is
|
|
887
|
+
* the filter — so the split is read before anything is chosen, and there is
|
|
888
|
+
* no row of tabs repeating the numbers under it. What the chosen kind was
|
|
889
|
+
* counted in (tokens, pictures, seconds) is the line under the cards; the
|
|
890
|
+
* others are not known until they are chosen, because the ledger is read
|
|
891
|
+
* for one kind at a time.
|
|
892
|
+
*/
|
|
893
|
+
const cards = el("div", "usage-kinds");
|
|
894
|
+
const total = Object.values(byKind).reduce((sum, each) => sum + (each.spentMicro || 0), 0);
|
|
895
|
+
const allCalls = Object.values(byKind).reduce((sum, each) => sum + (each.calls || 0), 0);
|
|
896
|
+
for (const id of USAGE_KINDS) {
|
|
897
|
+
const each = id === "all" ? { spentMicro: total, calls: allCalls } : byKind[id] || {};
|
|
898
|
+
const card = el("button", "usage-kind" + (id === kind ? " on" : "") + (each.spentMicro || each.calls ? "" : " empty"));
|
|
899
|
+
card.type = "button";
|
|
900
|
+
card.setAttribute("aria-pressed", id === kind ? "true" : "false");
|
|
901
|
+
card.appendChild(el("span", "usage-kind-label", t("usage.kind." + id)));
|
|
902
|
+
card.appendChild(el("span", "usage-kind-value", creditsOf(each.spentMicro || 0)));
|
|
903
|
+
card.appendChild(el("span", "usage-kind-sub", usageCalls(each.calls || 0)));
|
|
904
|
+
card.addEventListener("click", () => {
|
|
905
|
+
if (id === kind) return;
|
|
906
|
+
usageKind = id;
|
|
907
|
+
void showUsage();
|
|
908
|
+
});
|
|
909
|
+
cards.appendChild(card);
|
|
910
|
+
}
|
|
911
|
+
panel.appendChild(cards);
|
|
912
|
+
|
|
913
|
+
const media = summary.media || {};
|
|
914
|
+
const tokens = (summary.inputTokens || 0) + (summary.outputTokens || 0);
|
|
915
|
+
const counted = [];
|
|
916
|
+
if (tokens) {
|
|
917
|
+
counted.push(t("usage.tokens", { n: formatTokens(tokens) }) + " (" + t("usage.inOut", {
|
|
918
|
+
in: formatTokens(summary.inputTokens || 0),
|
|
919
|
+
out: formatTokens(summary.outputTokens || 0),
|
|
920
|
+
}) + ")");
|
|
921
|
+
}
|
|
922
|
+
if (media.images) counted.push(t("usage.images", { n: media.images.toLocaleString(LOCALE) }));
|
|
923
|
+
if (media.videoSeconds) counted.push(t("usage.videoSeconds", { n: usageSeconds(media.videoSeconds) }));
|
|
924
|
+
if (media.audioSeconds) counted.push(t("usage.audioSeconds", { n: usageSeconds(media.audioSeconds) }));
|
|
925
|
+
if (media.characters) counted.push(t("usage.characters", { n: media.characters.toLocaleString(LOCALE) }));
|
|
926
|
+
if (counted.length) panel.appendChild(el("div", "muted small usage-counted", counted.join(" · ")));
|
|
927
|
+
|
|
928
|
+
const series = summary.series || [];
|
|
929
|
+
if (series.length) {
|
|
930
|
+
const hourly = (summary.days || []).some((day) => day.at.length > 10);
|
|
931
|
+
const chartBody = {
|
|
932
|
+
series: {
|
|
933
|
+
step: hourly ? "hour" : "day",
|
|
934
|
+
buckets: (summary.days || []).map((day) => ({
|
|
935
|
+
at: day.at,
|
|
936
|
+
costUsd: (day.spentMicro || 0) / 1000000,
|
|
937
|
+
models: Object.fromEntries(Object.entries(day.byKey || {}).map(([key, value]) => [key, value / 1000000])),
|
|
938
|
+
})),
|
|
939
|
+
},
|
|
940
|
+
};
|
|
941
|
+
panel.appendChild(usageChart(chartBody, series.map((entry) => ({ model: entry.key })), {
|
|
942
|
+
money: (value) => usageCredits(value * 1000000),
|
|
943
|
+
// The ledger's buckets are UTC days and hours, and are read as such: a
|
|
944
|
+
// day drawn in local time would put yesterday's evening under today.
|
|
945
|
+
when: (at) => at.length > 10
|
|
946
|
+
? new Date(at + ":00:00Z").toLocaleString(LOCALE, { month: "short", day: "numeric", hour: "numeric" })
|
|
947
|
+
: new Date(at + "T00:00:00Z").toLocaleDateString(LOCALE, { timeZone: "UTC" }),
|
|
948
|
+
}));
|
|
949
|
+
|
|
950
|
+
// By model, and under "all" by kind first: forty models in one list is a
|
|
951
|
+
// list nobody reads, and the kind is the first thing anyone looks for.
|
|
952
|
+
const groups = kind === "all" ? USAGE_KINDS.slice(1) : [kind];
|
|
953
|
+
for (const group of groups) {
|
|
954
|
+
const rows = series.filter((entry) => entry.kind === group);
|
|
955
|
+
if (!rows.length) continue;
|
|
956
|
+
const title = el("h3", "section-title usage-kind-title");
|
|
957
|
+
title.appendChild(el("span", null, kind === "all" ? t("usage.kind." + group) : t("usage.byModel")));
|
|
958
|
+
title.appendChild(el("span", "muted small",
|
|
959
|
+
usageCredits(rows.reduce((sum, entry) => sum + (entry.spentMicro || 0), 0))));
|
|
960
|
+
panel.appendChild(title);
|
|
961
|
+
const groupTotal = rows.reduce((sum, entry) => sum + (entry.spentMicro || 0), 0);
|
|
962
|
+
panel.appendChild(usageScrollList(rows, (entry) => usageModelRow(
|
|
963
|
+
entry.key,
|
|
964
|
+
usageCredits(entry.spentMicro || 0),
|
|
965
|
+
groupTotal ? (entry.spentMicro || 0) / groupTotal : 0,
|
|
966
|
+
usageSeriesFacts(entry),
|
|
967
|
+
)));
|
|
968
|
+
}
|
|
969
|
+
} else {
|
|
970
|
+
panel.appendChild(el("p", "muted", t(kind === "all" ? "usage.none" : "usage.kindNone")));
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// What the numbers cover, and where the full ledger lives.
|
|
974
|
+
panel.appendChild(el("p", "muted small", t(body.team ? "usage.teamScope" : "usage.accountScope")));
|
|
975
|
+
if (body.site) {
|
|
976
|
+
const open = el("button", "ghost", t("usage.openSite"));
|
|
977
|
+
open.type = "button";
|
|
978
|
+
open.addEventListener("click", () =>
|
|
979
|
+
window.open(body.site + "/" + LOCALE + "/dashboard/usage", "_blank", "noopener"));
|
|
980
|
+
panel.appendChild(open);
|
|
981
|
+
}
|
|
982
|
+
panelBody.appendChild(panel);
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/** Sessions per request on the sessions page. */
|
|
986
|
+
const USAGE_SESSIONS_PAGE = 50;
|
|
987
|
+
|
|
988
|
+
/** Prompt tokens sent, and everything moved, for a session row. */
|
|
989
|
+
const usageSent = (row) => (row.input || 0) + (row.cacheRead || 0) + (row.cacheWrite || 0);
|
|
990
|
+
const usageMoved = (row) => usageSent(row) + (row.output || 0);
|
|
991
|
+
|
|
992
|
+
/**
|
|
993
|
+
* One session: what it cost, when, on what, and how much of it the cache
|
|
994
|
+
* carried — and a way back into it, which is what someone scanning a list of
|
|
995
|
+
* what they spent usually wants next.
|
|
996
|
+
*/
|
|
997
|
+
function usageSessionRow(entry) {
|
|
998
|
+
const row = el("div", "row-card usage-session");
|
|
999
|
+
const line = el("div", "row-head");
|
|
1000
|
+
line.appendChild(el("span", "row-title", entry.title || t("session.untitled")));
|
|
1001
|
+
if (entry.current) line.appendChild(el("span", "tag", t("usage.thisSession")));
|
|
1002
|
+
line.appendChild(el("span", "muted small usage-amount", "$" + (entry.costUsd || 0).toFixed(4)));
|
|
1003
|
+
row.appendChild(line);
|
|
1004
|
+
const detail = [ago(entry.updatedAt), entry.model, t("usage.tokens", { n: formatTokens(usageMoved(entry)) })];
|
|
1005
|
+
// The reason one session cost what it did is usually here: two runs of the
|
|
1006
|
+
// same length differ by what the cache carried.
|
|
1007
|
+
if (entry.cacheRead) detail.push(t("usage.cached", { pct: Math.round((entry.cacheRead / usageSent(entry)) * 100) }));
|
|
1008
|
+
row.appendChild(el("div", "muted small", detail.join(" · ")));
|
|
1009
|
+
row.tabIndex = 0;
|
|
1010
|
+
row.setAttribute("role", "button");
|
|
1011
|
+
const open = () => {
|
|
1012
|
+
setNav(null);
|
|
1013
|
+
if (panes.has(entry.id)) void focusTab(entry.id);
|
|
1014
|
+
else void openSession(entry.id, entry.cwd);
|
|
1015
|
+
};
|
|
1016
|
+
row.addEventListener("click", open);
|
|
1017
|
+
row.addEventListener("keydown", (event) => { if (event.key === "Enter") open(); });
|
|
1018
|
+
return row;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
/**
|
|
1022
|
+
* One model: its name with what it cost at the top right, then a bar for its
|
|
1023
|
+
* share of the list's spend with what it moved written across it. The bar is
|
|
1024
|
+
* what makes a list of forty readable — the two that carried the bill stand
|
|
1025
|
+
* out before a number is read.
|
|
1026
|
+
*/
|
|
1027
|
+
function usageModelRow(name, amount, share, details) {
|
|
1028
|
+
const row = el("div", "row-card usage-model");
|
|
1029
|
+
const line = el("div", "row-head");
|
|
1030
|
+
line.appendChild(el("span", "row-title", name));
|
|
1031
|
+
line.appendChild(el("span", "muted small usage-amount", amount));
|
|
1032
|
+
row.appendChild(line);
|
|
1033
|
+
const track = el("div", "usage-share");
|
|
1034
|
+
const fill = el("span", "usage-share-fill");
|
|
1035
|
+
// A sliver for anything that cost something, so it does not read as zero.
|
|
1036
|
+
fill.style.width = (share > 0 ? Math.max(share * 100, 0.8) : 0).toFixed(2) + "%";
|
|
1037
|
+
track.appendChild(fill);
|
|
1038
|
+
const pct = share >= 0.995 ? "100%" : share >= 0.01 ? Math.round(share * 100) + "%" : share > 0 ? "<1%" : "0%";
|
|
1039
|
+
const label = el("span", "usage-share-label");
|
|
1040
|
+
label.appendChild(el("span", "usage-model-pct", pct));
|
|
1041
|
+
if (details.length) label.appendChild(el("span", "usage-share-facts", details.join(" · ")));
|
|
1042
|
+
track.appendChild(label);
|
|
1043
|
+
row.appendChild(track);
|
|
1044
|
+
return row;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
/** What an account row moved: its tokens, or the media it made. */
|
|
1048
|
+
function usageSeriesFacts(entry) {
|
|
1049
|
+
const facts = [];
|
|
1050
|
+
const tokens = (entry.inputTokens || 0) + (entry.outputTokens || 0);
|
|
1051
|
+
if (tokens) facts.push(t("usage.tokens", { n: formatTokens(tokens) }));
|
|
1052
|
+
const media = entry.media || {};
|
|
1053
|
+
if (media.images) facts.push(t("usage.images", { n: media.images.toLocaleString(LOCALE) }));
|
|
1054
|
+
if (media.videoSeconds) facts.push(t("usage.videoSeconds", { n: usageSeconds(media.videoSeconds) }));
|
|
1055
|
+
if (media.audioSeconds) facts.push(t("usage.audioSeconds", { n: usageSeconds(media.audioSeconds) }));
|
|
1056
|
+
if (media.characters) facts.push(t("usage.characters", { n: media.characters.toLocaleString(LOCALE) }));
|
|
1057
|
+
return facts;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/** How many rows a model list draws at a time. */
|
|
1061
|
+
const USAGE_LIST_PAGE = 20;
|
|
1062
|
+
|
|
1063
|
+
/**
|
|
1064
|
+
* A list of models in a box of its own height.
|
|
1065
|
+
*
|
|
1066
|
+
* A heavy month on OpenRouter is a hundred models, and drawn in full they
|
|
1067
|
+
* pushed the sessions and the scope note a dozen screens down. The box keeps
|
|
1068
|
+
* the page's shape and scrolls on its own; rows are drawn a page at a time as
|
|
1069
|
+
* its bottom comes near, because most visits read the top few.
|
|
1070
|
+
*/
|
|
1071
|
+
function usageScrollList(entries, render) {
|
|
1072
|
+
const box = el("div", "usage-list");
|
|
1073
|
+
let shown = 0;
|
|
1074
|
+
function more() {
|
|
1075
|
+
for (const entry of entries.slice(shown, shown + USAGE_LIST_PAGE)) box.appendChild(render(entry));
|
|
1076
|
+
shown = Math.min(entries.length, shown + USAGE_LIST_PAGE);
|
|
1077
|
+
if (shown >= entries.length) box.removeEventListener("scroll", near);
|
|
1078
|
+
}
|
|
1079
|
+
// Read off the scroll position rather than an observer on a sentinel: a
|
|
1080
|
+
// sentinel that stays in view after a page lands never re-enters it, and
|
|
1081
|
+
// the list stopped at the second page.
|
|
1082
|
+
function near() {
|
|
1083
|
+
if (box.scrollTop + box.clientHeight >= box.scrollHeight - 160) more();
|
|
1084
|
+
}
|
|
1085
|
+
more();
|
|
1086
|
+
if (shown < entries.length) box.addEventListener("scroll", near, { passive: true });
|
|
1087
|
+
return box;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/** What the sessions page is searching for, kept while the page is open. */
|
|
1091
|
+
let usageSessionsQuery = "";
|
|
1092
|
+
|
|
1093
|
+
/**
|
|
1094
|
+
* Every session that spent something on this machine, newest first — the
|
|
1095
|
+
* Sessions page in the sidebar.
|
|
1096
|
+
*
|
|
1097
|
+
* Read a page at a time and drawn as the list is scrolled, because a machine
|
|
1098
|
+
* used daily has thousands and the first screen is the one almost always
|
|
1099
|
+
* wanted. The search runs on the server, so it finds sessions that have not
|
|
1100
|
+
* been loaded yet rather than filtering the ones that have.
|
|
1101
|
+
*/
|
|
1102
|
+
async function showUsageSessions() {
|
|
1103
|
+
panelBody.replaceChildren();
|
|
1104
|
+
const panel = el("div", "panel usage");
|
|
1105
|
+
const head = el("div", "row-head");
|
|
1106
|
+
head.appendChild(el("h2", null, t("usage.allSessions")));
|
|
1107
|
+
panel.appendChild(head);
|
|
1108
|
+
|
|
1109
|
+
const search = el("input", "art-search");
|
|
1110
|
+
search.type = "search";
|
|
1111
|
+
search.placeholder = t("usage.searchSessions");
|
|
1112
|
+
search.spellcheck = false;
|
|
1113
|
+
search.autocomplete = "off";
|
|
1114
|
+
search.value = usageSessionsQuery;
|
|
1115
|
+
panel.appendChild(search);
|
|
1116
|
+
|
|
1117
|
+
const count = el("div", "muted small usage-sessions-count");
|
|
1118
|
+
panel.appendChild(count);
|
|
1119
|
+
const list = el("div", "usage-sessions");
|
|
1120
|
+
panel.appendChild(list);
|
|
1121
|
+
const foot = el("div", "muted small usage-sessions-foot");
|
|
1122
|
+
panel.appendChild(foot);
|
|
1123
|
+
panelBody.appendChild(panel);
|
|
1124
|
+
|
|
1125
|
+
// Which search the rows on screen belong to. A reply to an older search
|
|
1126
|
+
// that lands after a newer one started is dropped, not appended.
|
|
1127
|
+
let generation = 0;
|
|
1128
|
+
let offset = 0;
|
|
1129
|
+
let more = true;
|
|
1130
|
+
let loading = false;
|
|
1131
|
+
|
|
1132
|
+
async function load() {
|
|
1133
|
+
if (loading || !more) return;
|
|
1134
|
+
loading = true;
|
|
1135
|
+
const asked = generation;
|
|
1136
|
+
foot.textContent = t("usage.loading");
|
|
1137
|
+
const body = await getJson("/usage/sessions?limit=" + USAGE_SESSIONS_PAGE + "&offset=" + offset +
|
|
1138
|
+
(usageSessionsQuery ? "&q=" + encodeURIComponent(usageSessionsQuery) : ""));
|
|
1139
|
+
loading = false;
|
|
1140
|
+
if (asked !== generation || shownPanel !== "nav-sessions") return;
|
|
1141
|
+
if (body.error) {
|
|
1142
|
+
foot.textContent = body.error;
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
1145
|
+
for (const entry of body.sessions || []) list.appendChild(usageSessionRow(entry));
|
|
1146
|
+
offset += (body.sessions || []).length;
|
|
1147
|
+
more = Boolean(body.more);
|
|
1148
|
+
count.textContent = body.total === 1 ? t("usage.sessionsOne") : t("usage.sessionsCount", { n: body.total || 0 });
|
|
1149
|
+
foot.textContent = !body.total
|
|
1150
|
+
? t(usageSessionsQuery ? "usage.sessionsNoMatch" : "usage.none")
|
|
1151
|
+
: more ? "" : body.truncated ? t("usage.sessionsCapped") : t("usage.sessionsEnd");
|
|
1152
|
+
// A first page shorter than the window leaves nothing to scroll, so the
|
|
1153
|
+
// observer below would never fire; ask again until the list fills it.
|
|
1154
|
+
if (more && sentinel.getBoundingClientRect().top < panelView.clientHeight + 400) void load();
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
function restart() {
|
|
1158
|
+
generation += 1;
|
|
1159
|
+
offset = 0;
|
|
1160
|
+
more = true;
|
|
1161
|
+
loading = false;
|
|
1162
|
+
list.replaceChildren();
|
|
1163
|
+
void load();
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// Loads the next page as the bottom of the list comes near, not when it is
|
|
1167
|
+
// reached, so scrolling does not stop at a spinner.
|
|
1168
|
+
const sentinel = el("div", "usage-sessions-sentinel");
|
|
1169
|
+
panel.appendChild(sentinel);
|
|
1170
|
+
const observer = new IntersectionObserver((entries) => {
|
|
1171
|
+
if (shownPanel !== "nav-sessions") return void observer.disconnect();
|
|
1172
|
+
if (entries.some((entry) => entry.isIntersecting)) void load();
|
|
1173
|
+
}, { root: panelView, rootMargin: "0px 0px 400px 0px" });
|
|
1174
|
+
observer.observe(sentinel);
|
|
1175
|
+
|
|
1176
|
+
let typing = null;
|
|
1177
|
+
search.addEventListener("input", () => {
|
|
1178
|
+
clearTimeout(typing);
|
|
1179
|
+
typing = setTimeout(() => {
|
|
1180
|
+
usageSessionsQuery = search.value.trim();
|
|
1181
|
+
restart();
|
|
1182
|
+
}, 250);
|
|
1183
|
+
});
|
|
1184
|
+
restart();
|
|
1185
|
+
search.focus();
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
async function showUsage() {
|
|
1189
|
+
if (usageSource !== "device") {
|
|
1190
|
+
const account = await getJson(
|
|
1191
|
+
usageQuery("/usage/account") + (usageKind !== "all" ? "&kind=" + encodeURIComponent(usageKind) : ""),
|
|
1192
|
+
);
|
|
1193
|
+
if (shownPanel !== "usage") return;
|
|
1194
|
+
usageSignedIn = Boolean(account.signedIn);
|
|
1195
|
+
if (account.signedIn) return void showAccountUsage(account);
|
|
1196
|
+
}
|
|
1197
|
+
const body = await getJson(usageQuery("/usage"));
|
|
1198
|
+
if (shownPanel !== "usage") return;
|
|
1199
|
+
panelBody.replaceChildren();
|
|
1200
|
+
if (body.error) return void panelBody.appendChild(panelError(body.error));
|
|
1201
|
+
|
|
1202
|
+
const panel = el("div", "panel usage");
|
|
1203
|
+
usageHead(panel);
|
|
1204
|
+
|
|
1205
|
+
/*
|
|
1206
|
+
* How far back the page is asking about.
|
|
1207
|
+
*
|
|
1208
|
+
* A segmented control rather than a dropdown: there are four answers, they
|
|
1209
|
+
* are read left to right as increasing time, and a menu would hide three of
|
|
1210
|
+
* them behind the one that happens to be chosen. It reloads rather than
|
|
1211
|
+
* filtering what is already here — the totals, the models and the sessions
|
|
1212
|
+
* all answer for the window, and a page where the chart moved and the
|
|
1213
|
+
* figures above it did not would be lying about one of them.
|
|
1214
|
+
*/
|
|
1215
|
+
panel.appendChild(usageWindowRow(body, ["7d", "30d", "90d", "all"]));
|
|
819
1216
|
|
|
820
1217
|
// Prompt tokens only. The cache share is a share of what was sent, and
|
|
821
1218
|
// nothing that was generated could have come from a cache.
|
|
@@ -877,46 +1274,11 @@ async function showUsage() {
|
|
|
877
1274
|
|
|
878
1275
|
if (models.length) {
|
|
879
1276
|
panel.appendChild(el("h3", "section-title", t("usage.byModel")));
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
row.appendChild(line);
|
|
886
|
-
const bits = [
|
|
887
|
-
t("usage.tokens", { n: formatTokens(moved(entry)) }),
|
|
888
|
-
overSessions(entry.sessions),
|
|
889
|
-
];
|
|
890
|
-
const share = cachePct(entry);
|
|
891
|
-
if (share !== null) bits.push(t("usage.cached", { pct: share }));
|
|
892
|
-
row.appendChild(el("div", "muted small", bits.join(" · ")));
|
|
893
|
-
panel.appendChild(row);
|
|
894
|
-
}
|
|
895
|
-
}
|
|
896
|
-
|
|
897
|
-
const sessions = body.sessions || [];
|
|
898
|
-
if (sessions.length) {
|
|
899
|
-
panel.appendChild(el("h3", "section-title", t("usage.bySession")));
|
|
900
|
-
for (const entry of sessions) {
|
|
901
|
-
const row = el("div", "row-card");
|
|
902
|
-
const line = el("div", "row-head");
|
|
903
|
-
line.appendChild(el("span", "row-title", entry.title || t("session.untitled")));
|
|
904
|
-
if (entry.current) line.appendChild(el("span", "tag", t("usage.thisSession")));
|
|
905
|
-
line.appendChild(el("span", "muted small", "$" + (entry.costUsd || 0).toFixed(4)));
|
|
906
|
-
row.appendChild(line);
|
|
907
|
-
const detail = [
|
|
908
|
-
ago(entry.updatedAt),
|
|
909
|
-
entry.model,
|
|
910
|
-
t("usage.tokens", { n: formatTokens(moved(entry)) }),
|
|
911
|
-
];
|
|
912
|
-
// The reason one session cost what it did is usually here: two runs of
|
|
913
|
-
// the same length differ by what the cache carried, and per-session is
|
|
914
|
-
// the only place that comparison can be made.
|
|
915
|
-
const hit = cachePct(entry);
|
|
916
|
-
if (hit !== null) detail.push(t("usage.cached", { pct: hit }));
|
|
917
|
-
row.appendChild(el("div", "muted small", detail.join(" · ")));
|
|
918
|
-
panel.appendChild(row);
|
|
919
|
-
}
|
|
1277
|
+
const spent = totals.costUsd || 0;
|
|
1278
|
+
panel.appendChild(usageScrollList(models, (entry) => {
|
|
1279
|
+
return usageModelRow(entry.model, "$" + (entry.costUsd || 0).toFixed(2), spent ? (entry.costUsd || 0) / spent : 0,
|
|
1280
|
+
[t("usage.tokens", { n: formatTokens(moved(entry)) })]);
|
|
1281
|
+
}));
|
|
920
1282
|
}
|
|
921
1283
|
|
|
922
1284
|
if (!models.length) panel.appendChild(el("p", "muted", t("usage.none")));
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"usage.js","sourceRoot":"","sources":["../../../src/page/client/usage.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,CAAC,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAA
|
|
1
|
+
{"version":3,"file":"usage.js","sourceRoot":"","sources":["../../../src/page/client/usage.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,CAAC,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;IA4xC3B,CAAC"}
|