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:
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user