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>
Signed| @@ -3633,6 +3633,8 @@ | ||
| 3633 | 3633 | </div> |
| 3634 | 3634 | <div id="nanogpt_form" data-source="nanogpt"> |
| 3635 | 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 | 3638 | <div class="flex-container"> |
| 3637 | 3639 | <input id="api_key_nanogpt" name="api_key_nanogpt" class="text_pole flex1" value="" type="text" autocomplete="off"> |
| 3638 | 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> |
| @@ -1191,5 +1191,103 @@ export async function initSecrets() { | ||
| 1191 | 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 | 1292 | registerSecretSlashCommands(); |
| 1195 | 1293 | } |
| @@ -6400,6 +6400,38 @@ body:not(.movingUI) .drawer-content.maximized { | ||
| 6400 | 6400 | background-color: rgba(241, 163, 163, 0.2); |
| 6401 | 6401 | } |
| 6402 | 6402 | |
| 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 | 6435 | @media (prefers-contrast: more) { |
| 6404 | 6436 | :root { |
| 6405 | 6437 | --interactable-outline-color: CanvasText; |
| @@ -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 | +}); | |
| @@ -40,6 +40,7 @@ import { router as classifyRouter } from './endpoints/classify.js'; | ||
| 40 | 40 | import { router as captionRouter } from './endpoints/caption.js'; |
| 41 | 41 | import { router as searchRouter } from './endpoints/search.js'; |
| 42 | 42 | import { router as openRouterRouter } from './endpoints/openrouter.js'; |
| 43 | +import { router as nanogptRouter } from './endpoints/nanogpt.js'; | |
| 43 | 44 | import { router as chatCompletionsRouter } from './endpoints/backends/chat-completions.js'; |
| 44 | 45 | import { router as koboldRouter } from './endpoints/backends/kobold.js'; |
| 45 | 46 | import { router as textCompletionsRouter } from './endpoints/backends/text-completions.js'; |
| @@ -174,6 +175,7 @@ export function setupPrivateEndpoints(app) { | ||
| 174 | 175 | app.use('/api/search', searchRouter); |
| 175 | 176 | app.use('/api/backends/text-completions', textCompletionsRouter); |
| 176 | 177 | app.use('/api/openrouter', openRouterRouter); |
| 178 | + app.use('/api/nanogpt', nanogptRouter); | |
| 177 | 179 | app.use('/api/backends/kobold', koboldRouter); |
| 178 | 180 | app.use('/api/backends/chat-completions', chatCompletionsRouter); |
| 179 | 181 | app.use('/api/speech', speechRouter); |