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 | </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> |
| @@ -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 | } |
| @@ -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 | } |
| 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 | @media (prefers-contrast: more) { | 6435 | @media (prefers-contrast: more) { |
| 6404 | :root { | 6436 | :root { |
| 6405 | --interactable-outline-color: CanvasText; | 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 | import { router as captionRouter } from './endpoints/caption.js'; | 40 | import { router as captionRouter } from './endpoints/caption.js'; |
| 41 | import { router as searchRouter } from './endpoints/search.js'; | 41 | import { router as searchRouter } from './endpoints/search.js'; |
| 42 | import { router as openRouterRouter } from './endpoints/openrouter.js'; | 42 | import { router as openRouterRouter } from './endpoints/openrouter.js'; |
| 43 | import { router as nanogptRouter } from './endpoints/nanogpt.js'; | ||
| 43 | import { router as chatCompletionsRouter } from './endpoints/backends/chat-completions.js'; | 44 | import { router as chatCompletionsRouter } from './endpoints/backends/chat-completions.js'; |
| 44 | import { router as koboldRouter } from './endpoints/backends/kobold.js'; | 45 | import { router as koboldRouter } from './endpoints/backends/kobold.js'; |
| 45 | import { router as textCompletionsRouter } from './endpoints/backends/text-completions.js'; | 46 | import { 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); |