From 7201d87f2ebe4f8656e92794b3bca75406d46d7e Mon Sep 17 00:00:00 2001 From: DeathStalker471 Date: Sun, 26 Apr 2026 14:13:06 -0700 Subject: [PATCH] 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> --- public/index.html | 2 + public/scripts/secrets.js | 98 ++++++++++++++++++++++++++++++++++++ public/style.css | 32 ++++++++++++ src/endpoints/nanogpt.js | 102 ++++++++++++++++++++++++++++++++++++++ src/server-startup.js | 2 + 5 files changed, 236 insertions(+) create mode 100644 src/endpoints/nanogpt.js diff --git a/public/index.html b/public/index.html index 0882f472e..75c3e0bbf 100644 --- a/public/index.html +++ b/public/index.html @@ -3633,6 +3633,8 @@

NanoGPT API Key

+ View Remaining Credits +
diff --git a/public/scripts/secrets.js b/public/scripts/secrets.js index 5b2b774bb..e7cf37f1f 100644 --- a/public/scripts/secrets.js +++ b/public/scripts/secrets.js @@ -1191,5 +1191,103 @@ export async function initSecrets() { toastr.error(t`Could not fetch OpenRouter credits. Please try again.`); } }); + + const formatNanoGptNumber = (num, decimals = null) => { + const number = Number(num); + if (!Number.isFinite(number)) return decimals === null ? '0' : (0).toFixed(decimals); + if (decimals !== null) return number.toFixed(decimals); + if (number >= 1000000) return (number / 1000000).toFixed(1) + 'M'; + if (number >= 1000) return (number / 1000).toFixed(1) + 'K'; + return number.toString(); + }; + + const createNanoGptCreditsPopup = (credits) => { + const root = $('
'); + root.append($('

').text(t`NanoGPT Credits & Usage`)); + + const rows = [ + [t`USD`, `$${formatNanoGptNumber(credits.usdBalance, 2)}`], + [t`NANO`, formatNanoGptNumber(credits.nanoBalance, 3)], + ]; + + const addUsage = (label, usage, limit) => { + if (usage) { + rows.push([label, t`${formatNanoGptNumber(usage.used)} / ${formatNanoGptNumber(limit)} (${formatNanoGptNumber(usage.remaining)} left)`]); + } + }; + + if (credits.subscription?.active) { + const sub = credits.subscription; + const subEndDate = sub.period?.currentPeriodEnd ? moment(sub.period.currentPeriodEnd).format('LL') : t`Unknown`; + rows.push([t`Sub`, t`Active (until ${subEndDate})`]); + addUsage(t`Tokens/wk`, sub.weekly_tokens, sub.limits?.weeklyInputTokens); + addUsage(t`Tokens/day`, sub.daily_tokens, sub.limits?.dailyInputTokens); + addUsage(t`Images/day`, sub.daily_images, sub.limits?.dailyImages); + } + + for (const [label, value] of rows) { + root.append($('
').text(label)); + root.append($('
').text(value)); + } + + return root; + }; + + $(document).on('click', '.nanogpt_view_credits', async function (event) { + event.preventDefault(); + const display = $(this).siblings('.nanogpt_credits_display').first(); + display.empty().text(t`Loading…`); + + try { + const response = await fetch('/api/nanogpt/credits', { + method: 'POST', + headers: getRequestHeaders(), + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const data = await response.json(); + + const usdBalance = Number(data.usd_balance); + const nanoBalance = Number(data.nano_balance); + if (!Number.isFinite(usdBalance) || !Number.isFinite(nanoBalance)) { + throw new Error('Invalid response'); + } + + let balances = [`$${formatNanoGptNumber(usdBalance, 2)}`]; + if (nanoBalance > 0) { + balances.push(`${formatNanoGptNumber(nanoBalance, 3)} NANO`); + } + let shortInlineText = balances.join(' | '); + + if (data.subscription?.active) { + shortInlineText += ` | ${t`Sub Active`}`; + } + + display.empty().text(shortInlineText + ' '); + + const infoBtn = $(''); + infoBtn.attr('title', t`View details`); + infoBtn.data('credits', { + usdBalance, + nanoBalance, + subscription: data.subscription, + }); + display.append(infoBtn); + } catch (error) { + console.error('Failed to fetch NanoGPT credits:', error); + display.empty().text(''); + toastr.error(t`Could not fetch NanoGPT credits. Please try again.`); + } + }); + + $(document).on('click', '.nanogpt_info_btn', async function () { + const credits = $(this).data('credits'); + if (credits) { + await callGenericPopup(createNanoGptCreditsPopup(credits), POPUP_TYPE.TEXT); + } + }); registerSecretSlashCommands(); } diff --git a/public/style.css b/public/style.css index afae0767f..054a6b825 100644 --- a/public/style.css +++ b/public/style.css @@ -6400,6 +6400,38 @@ body:not(.movingUI) .drawer-content.maximized { background-color: rgba(241, 163, 163, 0.2); } +.nanogpt_info_btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.8em; + min-height: 1.8em; + margin-left: 0.15em; + vertical-align: middle; +} + +.nanogpt-credits-popup { + display: grid; + gap: 0.25em 1em; + grid-template-columns: max-content minmax(0, 1fr); + text-align: left; +} + +.nanogpt-credits-popup h3 { + grid-column: 1 / -1; + margin: 0 0 0.25em; +} + +.nanogpt-credits-popup div:nth-of-type(odd) { + opacity: 0.8; + white-space: nowrap; +} + +.nanogpt-credits-popup div:nth-of-type(even) { + font-weight: 600; + overflow-wrap: anywhere; +} + @media (prefers-contrast: more) { :root { --interactable-outline-color: CanvasText; diff --git a/src/endpoints/nanogpt.js b/src/endpoints/nanogpt.js new file mode 100644 index 000000000..935df150c --- /dev/null +++ b/src/endpoints/nanogpt.js @@ -0,0 +1,102 @@ +import express from 'express'; +import fetch from 'node-fetch'; +import { readSecret, SECRET_KEYS } from './secrets.js'; + +export const router = express.Router(); +const API_NANOGPT = 'https://nano-gpt.com/api'; + +/** + * Parses a numeric API value, returning 0 for missing or invalid values. + * @param {unknown} value Value to parse. + * @returns {number} + */ +function parseNumber(value) { + const number = Number(value); + return Number.isFinite(number) ? number : 0; +} + +/** + * Normalizes a NanoGPT usage bucket. + * @param {any} usage Usage bucket from NanoGPT. + * @returns {{ used: number, remaining: number, percentUsed: number, resetAt: number } | null} + */ +function normalizeUsage(usage) { + if (!usage || typeof usage !== 'object') { + return null; + } + + return { + used: parseNumber(usage.used), + remaining: parseNumber(usage.remaining), + percentUsed: parseNumber(usage.percentUsed), + resetAt: parseNumber(usage.resetAt), + }; +} + +router.post('/credits', async (req, res) => { + try { + const key = readSecret(req.user.directories, SECRET_KEYS.NANOGPT); + + if (!key) { + console.warn('NanoGPT API key not found'); + return res.sendStatus(400); + } + + const headers = { + 'Accept': 'application/json', + 'x-api-key': key, + }; + + // Fetch both Pay-As-You-Go balance and subscription usage at the same time. + const [balanceReq, subReq] = await Promise.allSettled([ + fetch(`${API_NANOGPT}/check-balance`, { method: 'POST', headers }), + fetch(`${API_NANOGPT}/subscription/v1/usage`, { method: 'GET', headers }), + ]); + + if (balanceReq.status !== 'fulfilled' || !balanceReq.value.ok) { + console.warn('NanoGPT balance request failed', balanceReq.status === 'fulfilled' ? balanceReq.value.statusText : balanceReq.reason); + return res.sendStatus(500); + } + + /** @type {any} */ + const balanceData = await balanceReq.value.json(); + /** @type {any} */ + const result = { + usd_balance: parseNumber(balanceData.usd_balance), + nano_balance: parseNumber(balanceData.nano_balance), + subscription: null, + }; + + if (subReq.status === 'fulfilled' && subReq.value.ok) { + /** @type {any} */ + const subData = await subReq.value.json(); + if (subData.active) { + result.subscription = { + active: true, + state: String(subData.state || ''), + allowOverage: Boolean(subData.allowOverage), + period: { + currentPeriodEnd: String(subData.period?.currentPeriodEnd || ''), + }, + limits: { + weeklyInputTokens: parseNumber(subData.limits?.weeklyInputTokens), + dailyInputTokens: parseNumber(subData.limits?.dailyInputTokens), + dailyImages: parseNumber(subData.limits?.dailyImages), + }, + weekly_tokens: normalizeUsage(subData.weeklyInputTokens), + daily_tokens: normalizeUsage(subData.dailyInputTokens), + daily_images: normalizeUsage(subData.dailyImages), + }; + } + } else if (subReq.status === 'fulfilled') { + console.warn('NanoGPT subscription usage request failed', subReq.value.statusText); + } else { + console.warn('NanoGPT subscription usage request failed', subReq.reason); + } + + return res.json(result); + } catch (error) { + console.error(error); + return res.sendStatus(500); + } +}); diff --git a/src/server-startup.js b/src/server-startup.js index 0769e166a..4bfc843b8 100644 --- a/src/server-startup.js +++ b/src/server-startup.js @@ -40,6 +40,7 @@ import { router as classifyRouter } from './endpoints/classify.js'; import { router as captionRouter } from './endpoints/caption.js'; import { router as searchRouter } from './endpoints/search.js'; import { router as openRouterRouter } from './endpoints/openrouter.js'; +import { router as nanogptRouter } from './endpoints/nanogpt.js'; import { router as chatCompletionsRouter } from './endpoints/backends/chat-completions.js'; import { router as koboldRouter } from './endpoints/backends/kobold.js'; import { router as textCompletionsRouter } from './endpoints/backends/text-completions.js'; @@ -174,6 +175,7 @@ export function setupPrivateEndpoints(app) { app.use('/api/search', searchRouter); app.use('/api/backends/text-completions', textCompletionsRouter); app.use('/api/openrouter', openRouterRouter); + app.use('/api/nanogpt', nanogptRouter); app.use('/api/backends/kobold', koboldRouter); app.use('/api/backends/chat-completions', chatCompletionsRouter); app.use('/api/speech', speechRouter);