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);