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>
This commit is contained in:
DeathStalker471
2026-04-26 14:13:06 -07:00
committed by GitHub
parent d327412e29
commit 7201d87f2e
5 changed files with 236 additions and 0 deletions
+2
View File
@@ -3633,6 +3633,8 @@
</div> </div>
<div id="nanogpt_form" data-source="nanogpt"> <div id="nanogpt_form" data-source="nanogpt">
<h4 data-i18n="NanoGPT API Key">NanoGPT API Key</h4> <h4 data-i18n="NanoGPT API Key">NanoGPT API Key</h4>
<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>
<span class="nanogpt_credits_display marginLeft5"></span>
<div class="flex-container"> <div class="flex-container">
<input id="api_key_nanogpt" name="api_key_nanogpt" class="text_pole flex1" value="" type="text" autocomplete="off"> <input id="api_key_nanogpt" name="api_key_nanogpt" class="text_pole flex1" value="" type="text" autocomplete="off">
<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> <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>
+98
View File
@@ -1191,5 +1191,103 @@ export async function initSecrets() {
toastr.error(t`Could not fetch OpenRouter credits. Please try again.`); 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 = $('<div class="nanogpt-credits-popup"></div>');
root.append($('<h3></h3>').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($('<div></div>').text(label));
root.append($('<div></div>').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 = $('<i class="fa-solid fa-circle-info cursor-pointer nanogpt_info_btn"></i>');
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(); registerSecretSlashCommands();
} }
+32
View File
@@ -6400,6 +6400,38 @@ body:not(.movingUI) .drawer-content.maximized {
background-color: rgba(241, 163, 163, 0.2); 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) { @media (prefers-contrast: more) {
:root { :root {
--interactable-outline-color: CanvasText; --interactable-outline-color: CanvasText;
+102
View File
@@ -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);
}
});
+2
View File
@@ -40,6 +40,7 @@ import { router as classifyRouter } from './endpoints/classify.js';
import { router as captionRouter } from './endpoints/caption.js'; import { router as captionRouter } from './endpoints/caption.js';
import { router as searchRouter } from './endpoints/search.js'; import { router as searchRouter } from './endpoints/search.js';
import { router as openRouterRouter } from './endpoints/openrouter.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 chatCompletionsRouter } from './endpoints/backends/chat-completions.js';
import { router as koboldRouter } from './endpoints/backends/kobold.js'; import { router as koboldRouter } from './endpoints/backends/kobold.js';
import { router as textCompletionsRouter } from './endpoints/backends/text-completions.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/search', searchRouter);
app.use('/api/backends/text-completions', textCompletionsRouter); app.use('/api/backends/text-completions', textCompletionsRouter);
app.use('/api/openrouter', openRouterRouter); app.use('/api/openrouter', openRouterRouter);
app.use('/api/nanogpt', nanogptRouter);
app.use('/api/backends/kobold', koboldRouter); app.use('/api/backends/kobold', koboldRouter);
app.use('/api/backends/chat-completions', chatCompletionsRouter); app.use('/api/backends/chat-completions', chatCompletionsRouter);
app.use('/api/speech', speechRouter); app.use('/api/speech', speechRouter);