feat: Add NanoGPT credit stats UI (#5537) * Add NanoGPT credit stats UI * fix lint * fix: type check * fix: migrate inline styles to css * feat: add sub active date display * feat: add link to balance page --------- Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>

7201d87f2ebe4f8656e92794b3bca75406d46d7e

DeathStalker471 <thomasnealson85@gmail.com>

Signed
5 files changed, +236 -0Ignore whitespace
public/index.html+2 -0
@@ -3633,6 +3633,8 @@
36333633 </div>
36343634 <div id="nanogpt_form" data-source="nanogpt">
36353635 <h4 data-i18n="NanoGPT API Key">NanoGPT API Key</h4>
3636+ <a href="https://nano-gpt.com/balance" target="_blank" rel="noopener noreferrer" class="nanogpt_view_credits" data-i18n="View Remaining Credits">View Remaining Credits</a>
3637+ <span class="nanogpt_credits_display marginLeft5"></span>
36363638 <div class="flex-container">
36373639 <input id="api_key_nanogpt" name="api_key_nanogpt" class="text_pole flex1" value="" type="text" autocomplete="off">
36383640 <div title="Manage API keys" data-i18n="[title]Manage API keys" class="menu_button fa-solid fa-key fa-fw manage-api-keys" data-key="api_key_nanogpt"></div>
public/scripts/secrets.js+98 -0
@@ -1191,5 +1191,103 @@ export async function initSecrets() {
11911191 toastr.error(t`Could not fetch OpenRouter credits. Please try again.`);
11921192 }
11931193 });
1194+
1195+ const formatNanoGptNumber = (num, decimals = null) => {
1196+ const number = Number(num);
1197+ if (!Number.isFinite(number)) return decimals === null ? '0' : (0).toFixed(decimals);
1198+ if (decimals !== null) return number.toFixed(decimals);
1199+ if (number >= 1000000) return (number / 1000000).toFixed(1) + 'M';
1200+ if (number >= 1000) return (number / 1000).toFixed(1) + 'K';
1201+ return number.toString();
1202+ };
1203+
1204+ const createNanoGptCreditsPopup = (credits) => {
1205+ const root = $('<div class="nanogpt-credits-popup"></div>');
1206+ root.append($('<h3></h3>').text(t`NanoGPT Credits & Usage`));
1207+
1208+ const rows = [
1209+ [t`USD`, `$${formatNanoGptNumber(credits.usdBalance, 2)}`],
1210+ [t`NANO`, formatNanoGptNumber(credits.nanoBalance, 3)],
1211+ ];
1212+
1213+ const addUsage = (label, usage, limit) => {
1214+ if (usage) {
1215+ rows.push([label, t`${formatNanoGptNumber(usage.used)} / ${formatNanoGptNumber(limit)} (${formatNanoGptNumber(usage.remaining)} left)`]);
1216+ }
1217+ };
1218+
1219+ if (credits.subscription?.active) {
1220+ const sub = credits.subscription;
1221+ const subEndDate = sub.period?.currentPeriodEnd ? moment(sub.period.currentPeriodEnd).format('LL') : t`Unknown`;
1222+ rows.push([t`Sub`, t`Active (until ${subEndDate})`]);
1223+ addUsage(t`Tokens/wk`, sub.weekly_tokens, sub.limits?.weeklyInputTokens);
1224+ addUsage(t`Tokens/day`, sub.daily_tokens, sub.limits?.dailyInputTokens);
1225+ addUsage(t`Images/day`, sub.daily_images, sub.limits?.dailyImages);
1226+ }
1227+
1228+ for (const [label, value] of rows) {
1229+ root.append($('<div></div>').text(label));
1230+ root.append($('<div></div>').text(value));
1231+ }
1232+
1233+ return root;
1234+ };
1235+
1236+ $(document).on('click', '.nanogpt_view_credits', async function (event) {
1237+ event.preventDefault();
1238+ const display = $(this).siblings('.nanogpt_credits_display').first();
1239+ display.empty().text(t`Loading…`);
1240+
1241+ try {
1242+ const response = await fetch('/api/nanogpt/credits', {
1243+ method: 'POST',
1244+ headers: getRequestHeaders(),
1245+ });
1246+
1247+ if (!response.ok) {
1248+ throw new Error(`HTTP ${response.status}`);
1249+ }
1250+
1251+ const data = await response.json();
1252+
1253+ const usdBalance = Number(data.usd_balance);
1254+ const nanoBalance = Number(data.nano_balance);
1255+ if (!Number.isFinite(usdBalance) || !Number.isFinite(nanoBalance)) {
1256+ throw new Error('Invalid response');
1257+ }
1258+
1259+ let balances = [`$${formatNanoGptNumber(usdBalance, 2)}`];
1260+ if (nanoBalance > 0) {
1261+ balances.push(`${formatNanoGptNumber(nanoBalance, 3)} NANO`);
1262+ }
1263+ let shortInlineText = balances.join(' | ');
1264+
1265+ if (data.subscription?.active) {
1266+ shortInlineText += ` | ${t`Sub Active`}`;
1267+ }
1268+
1269+ display.empty().text(shortInlineText + ' ');
1270+
1271+ const infoBtn = $('<i class="fa-solid fa-circle-info cursor-pointer nanogpt_info_btn"></i>');
1272+ infoBtn.attr('title', t`View details`);
1273+ infoBtn.data('credits', {
1274+ usdBalance,
1275+ nanoBalance,
1276+ subscription: data.subscription,
1277+ });
1278+ display.append(infoBtn);
1279+ } catch (error) {
1280+ console.error('Failed to fetch NanoGPT credits:', error);
1281+ display.empty().text('');
1282+ toastr.error(t`Could not fetch NanoGPT credits. Please try again.`);
1283+ }
1284+ });
1285+
1286+ $(document).on('click', '.nanogpt_info_btn', async function () {
1287+ const credits = $(this).data('credits');
1288+ if (credits) {
1289+ await callGenericPopup(createNanoGptCreditsPopup(credits), POPUP_TYPE.TEXT);
1290+ }
1291+ });
11941292 registerSecretSlashCommands();
11951293}
public/style.css+32 -0
@@ -6400,6 +6400,38 @@ body:not(.movingUI) .drawer-content.maximized {
64006400 background-color: rgba(241, 163, 163, 0.2);
64016401}
64026402
6403+.nanogpt_info_btn {
6404+ display: inline-flex;
6405+ align-items: center;
6406+ justify-content: center;
6407+ min-width: 1.8em;
6408+ min-height: 1.8em;
6409+ margin-left: 0.15em;
6410+ vertical-align: middle;
6411+}
6412+
6413+.nanogpt-credits-popup {
6414+ display: grid;
6415+ gap: 0.25em 1em;
6416+ grid-template-columns: max-content minmax(0, 1fr);
6417+ text-align: left;
6418+}
6419+
6420+.nanogpt-credits-popup h3 {
6421+ grid-column: 1 / -1;
6422+ margin: 0 0 0.25em;
6423+}
6424+
6425+.nanogpt-credits-popup div:nth-of-type(odd) {
6426+ opacity: 0.8;
6427+ white-space: nowrap;
6428+}
6429+
6430+.nanogpt-credits-popup div:nth-of-type(even) {
6431+ font-weight: 600;
6432+ overflow-wrap: anywhere;
6433+}
6434+
64036435@media (prefers-contrast: more) {
64046436 :root {
64056437 --interactable-outline-color: CanvasText;
src/endpoints/nanogpt.js+102 -0
@@ -0,0 +1,102 @@
1+import express from 'express';
2+import fetch from 'node-fetch';
3+import { readSecret, SECRET_KEYS } from './secrets.js';
4+
5+export const router = express.Router();
6+const API_NANOGPT = 'https://nano-gpt.com/api';
7+
8+/**
9+ * Parses a numeric API value, returning 0 for missing or invalid values.
10+ * @param {unknown} value Value to parse.
11+ * @returns {number}
12+ */
13+function parseNumber(value) {
14+ const number = Number(value);
15+ return Number.isFinite(number) ? number : 0;
16+}
17+
18+/**
19+ * Normalizes a NanoGPT usage bucket.
20+ * @param {any} usage Usage bucket from NanoGPT.
21+ * @returns {{ used: number, remaining: number, percentUsed: number, resetAt: number } | null}
22+ */
23+function normalizeUsage(usage) {
24+ if (!usage || typeof usage !== 'object') {
25+ return null;
26+ }
27+
28+ return {
29+ used: parseNumber(usage.used),
30+ remaining: parseNumber(usage.remaining),
31+ percentUsed: parseNumber(usage.percentUsed),
32+ resetAt: parseNumber(usage.resetAt),
33+ };
34+}
35+
36+router.post('/credits', async (req, res) => {
37+ try {
38+ const key = readSecret(req.user.directories, SECRET_KEYS.NANOGPT);
39+
40+ if (!key) {
41+ console.warn('NanoGPT API key not found');
42+ return res.sendStatus(400);
43+ }
44+
45+ const headers = {
46+ 'Accept': 'application/json',
47+ 'x-api-key': key,
48+ };
49+
50+ // Fetch both Pay-As-You-Go balance and subscription usage at the same time.
51+ const [balanceReq, subReq] = await Promise.allSettled([
52+ fetch(`${API_NANOGPT}/check-balance`, { method: 'POST', headers }),
53+ fetch(`${API_NANOGPT}/subscription/v1/usage`, { method: 'GET', headers }),
54+ ]);
55+
56+ if (balanceReq.status !== 'fulfilled' || !balanceReq.value.ok) {
57+ console.warn('NanoGPT balance request failed', balanceReq.status === 'fulfilled' ? balanceReq.value.statusText : balanceReq.reason);
58+ return res.sendStatus(500);
59+ }
60+
61+ /** @type {any} */
62+ const balanceData = await balanceReq.value.json();
63+ /** @type {any} */
64+ const result = {
65+ usd_balance: parseNumber(balanceData.usd_balance),
66+ nano_balance: parseNumber(balanceData.nano_balance),
67+ subscription: null,
68+ };
69+
70+ if (subReq.status === 'fulfilled' && subReq.value.ok) {
71+ /** @type {any} */
72+ const subData = await subReq.value.json();
73+ if (subData.active) {
74+ result.subscription = {
75+ active: true,
76+ state: String(subData.state || ''),
77+ allowOverage: Boolean(subData.allowOverage),
78+ period: {
79+ currentPeriodEnd: String(subData.period?.currentPeriodEnd || ''),
80+ },
81+ limits: {
82+ weeklyInputTokens: parseNumber(subData.limits?.weeklyInputTokens),
83+ dailyInputTokens: parseNumber(subData.limits?.dailyInputTokens),
84+ dailyImages: parseNumber(subData.limits?.dailyImages),
85+ },
86+ weekly_tokens: normalizeUsage(subData.weeklyInputTokens),
87+ daily_tokens: normalizeUsage(subData.dailyInputTokens),
88+ daily_images: normalizeUsage(subData.dailyImages),
89+ };
90+ }
91+ } else if (subReq.status === 'fulfilled') {
92+ console.warn('NanoGPT subscription usage request failed', subReq.value.statusText);
93+ } else {
94+ console.warn('NanoGPT subscription usage request failed', subReq.reason);
95+ }
96+
97+ return res.json(result);
98+ } catch (error) {
99+ console.error(error);
100+ return res.sendStatus(500);
101+ }
102+});
src/server-startup.js+2 -0
@@ -40,6 +40,7 @@ import { router as classifyRouter } from './endpoints/classify.js';
4040import { router as captionRouter } from './endpoints/caption.js';
4141import { router as searchRouter } from './endpoints/search.js';
4242import { router as openRouterRouter } from './endpoints/openrouter.js';
43+import { router as nanogptRouter } from './endpoints/nanogpt.js';
4344import { router as chatCompletionsRouter } from './endpoints/backends/chat-completions.js';
4445import { router as koboldRouter } from './endpoints/backends/kobold.js';
4546import { router as textCompletionsRouter } from './endpoints/backends/text-completions.js';
@@ -174,6 +175,7 @@ export function setupPrivateEndpoints(app) {
174175 app.use('/api/search', searchRouter);
175176 app.use('/api/backends/text-completions', textCompletionsRouter);
176177 app.use('/api/openrouter', openRouterRouter);
178+ app.use('/api/nanogpt', nanogptRouter);
177179 app.use('/api/backends/kobold', koboldRouter);
178180 app.use('/api/backends/chat-completions', chatCompletionsRouter);
179181 app.use('/api/speech', speechRouter);