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 @@
3633 </div>3633 </div>
3634 <div id="nanogpt_form" data-source="nanogpt">3634 <div id="nanogpt_form" data-source="nanogpt">
3635 <h4 data-i18n="NanoGPT API Key">NanoGPT API Key</h4>3635 <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>
3636 <div class="flex-container">3638 <div class="flex-container">
3637 <input id="api_key_nanogpt" name="api_key_nanogpt" class="text_pole flex1" value="" type="text" autocomplete="off">3639 <input id="api_key_nanogpt" name="api_key_nanogpt" class="text_pole flex1" value="" type="text" autocomplete="off">
3638 <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>3640 <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() {
1191 toastr.error(t`Could not fetch OpenRouter credits. Please try again.`);1191 toastr.error(t`Could not fetch OpenRouter credits. Please try again.`);
1192 }1192 }
1193 });1193 });
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 });
1194 registerSecretSlashCommands();1292 registerSecretSlashCommands();
1195}1293}
public/style.css+32 -0
@@ -6400,6 +6400,38 @@ body:not(.movingUI) .drawer-content.maximized {
6400 background-color: rgba(241, 163, 163, 0.2);6400 background-color: rgba(241, 163, 163, 0.2);
6401}6401}
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
6403@media (prefers-contrast: more) {6435@media (prefers-contrast: more) {
6404 :root {6436 :root {
6405 --interactable-outline-color: CanvasText;6437 --interactable-outline-color: CanvasText;
src/endpoints/nanogpt.js+102 -0
@@ -0,0 +1,102 @@
1import express from 'express';
2import fetch from 'node-fetch';
3import { readSecret, SECRET_KEYS } from './secrets.js';
4
5export const router = express.Router();
6const 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 */
13function 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 */
23function 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
36router.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';
40import { router as captionRouter } from './endpoints/caption.js';40import { router as captionRouter } from './endpoints/caption.js';
41import { router as searchRouter } from './endpoints/search.js';41import { router as searchRouter } from './endpoints/search.js';
42import { router as openRouterRouter } from './endpoints/openrouter.js';42import { router as openRouterRouter } from './endpoints/openrouter.js';
43import { router as nanogptRouter } from './endpoints/nanogpt.js';
43import { router as chatCompletionsRouter } from './endpoints/backends/chat-completions.js';44import { router as chatCompletionsRouter } from './endpoints/backends/chat-completions.js';
44import { router as koboldRouter } from './endpoints/backends/kobold.js';45import { router as koboldRouter } from './endpoints/backends/kobold.js';
45import { router as textCompletionsRouter } from './endpoints/backends/text-completions.js';46import { router as textCompletionsRouter } from './endpoints/backends/text-completions.js';
@@ -174,6 +175,7 @@ export function setupPrivateEndpoints(app) {
174 app.use('/api/search', searchRouter);175 app.use('/api/search', searchRouter);
175 app.use('/api/backends/text-completions', textCompletionsRouter);176 app.use('/api/backends/text-completions', textCompletionsRouter);
176 app.use('/api/openrouter', openRouterRouter);177 app.use('/api/openrouter', openRouterRouter);
178 app.use('/api/nanogpt', nanogptRouter);
177 app.use('/api/backends/kobold', koboldRouter);179 app.use('/api/backends/kobold', koboldRouter);
178 app.use('/api/backends/chat-completions', chatCompletionsRouter);180 app.use('/api/backends/chat-completions', chatCompletionsRouter);
179 app.use('/api/speech', speechRouter);181 app.use('/api/speech', speechRouter);