feat(usage): add NanoGPT subscription quota tracking to Limits & Quotas dashboard (#1865)

Integrated into release/v3.7.8
This commit is contained in:
Aculeasis
2026-05-02 03:29:13 +03:00
committed by GitHub
parent a10a4bf491
commit 00182afb2f
6 changed files with 190 additions and 1 deletions

View File

@@ -72,6 +72,10 @@ const CURSOR_USAGE_CONFIG = {
clientVersion: CURSOR_REGISTRY_VERSION,
};
const NANOGPT_CONFIG = {
usageUrl: "https://nano-gpt.com/api/subscription/v1/usage",
};
const MINIMAX_USAGE_CONFIG = {
minimax: {
usageUrls: [
@@ -595,6 +599,86 @@ async function getBailianCodingPlanUsage(
}
}
/**
* NanoGPT Usage
* Fetches subscription-level quota from the NanoGPT API.
* Returns daily/weekly token limits and daily image limits for PRO accounts.
*/
async function getNanoGptUsage(apiKey: string) {
if (!apiKey) {
return { message: "NanoGPT API key not available. Add a key to view usage." };
}
try {
const res = await fetch(NANOGPT_CONFIG.usageUrl, {
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!res.ok) {
if (res.status === 401) return { message: "Invalid NanoGPT API key." };
return { message: `NanoGPT quota API error (${res.status})` };
}
const data = toRecord(await res.json());
const quotas: Record<string, UsageQuota> = {};
// active -> PRO, otherwise FREE
const plan = data.active ? "PRO" : "FREE";
if (data.active) {
// 1. Tokens limit
// dailyInputTokens if exists, else weeklyInputTokens
let tokenQuota = toRecord(data.dailyInputTokens);
let tokenLabel = "Daily Tokens";
if (!tokenQuota.resetAt) {
const weeklyQuota = toRecord(data.weeklyInputTokens);
if (weeklyQuota.remaining !== undefined) {
tokenQuota = weeklyQuota;
tokenLabel = "Weekly Tokens";
}
}
if (tokenQuota.remaining !== undefined) {
const used = toNumber(tokenQuota.used, 0);
const remaining = toNumber(tokenQuota.remaining, 0);
const total = used + remaining;
quotas[tokenLabel] = {
used,
total,
remaining,
remainingPercentage: clampPercentage(100 - toNumber(tokenQuota.percentUsed, 0) * 100),
resetAt: parseResetTime(tokenQuota.resetAt),
unlimited: false,
};
}
// 2. Images limit
const imageQuota = toRecord(data.dailyImages);
if (imageQuota.remaining !== undefined) {
const used = toNumber(imageQuota.used, 0);
const remaining = toNumber(imageQuota.remaining, 0);
const total = used + remaining;
quotas["Daily Images"] = {
used,
total,
remaining,
remainingPercentage: clampPercentage(100 - toNumber(imageQuota.percentUsed, 0) * 100),
resetAt: parseResetTime(imageQuota.resetAt),
unlimited: false,
};
}
if (Object.keys(quotas).length === 0) {
return { plan, message: "NanoGPT connected, but no active limits found." };
}
}
return { plan, quotas };
} catch (error) {
return { message: `NanoGPT connected. Unable to fetch usage: ${(error as Error).message}` };
}
}
/**
* Get usage data for a provider connection
* @param {Object} connection - Provider connection with accessToken
@@ -635,6 +719,8 @@ export async function getUsageForProvider(connection, options: { forceRefresh?:
return await getCursorUsage(accessToken);
case "bailian-coding-plan":
return await getBailianCodingPlanUsage(id, apiKey, providerSpecificData);
case "nanogpt":
return await getNanoGptUsage(apiKey);
default:
return { message: `Usage API not implemented for ${provider}` };
}

View File

@@ -40,6 +40,7 @@ const PROVIDER_CONFIG = {
"kimi-coding": { label: "Kimi Coding", color: "#1E3A8A" },
minimax: { label: "MiniMax", color: "#7C3AED" },
"minimax-cn": { label: "MiniMax CN", color: "#DC2626" },
nanogpt: { label: "NanoGPT", color: "#4F46E5" },
};
const TIER_FILTERS = [
@@ -301,6 +302,7 @@ export default function ProviderLimits() {
"kimi-coding": 9,
minimax: 10,
"minimax-cn": 11,
nanogpt: 12,
};
return [...filteredConnections].sort(
(a, b) => (priority[a.provider] || 9) - (priority[b.provider] || 9)

View File

@@ -289,6 +289,14 @@ export function parseQuotaData(provider, data) {
}
break;
case "nanogpt":
if (data.quotas) {
Object.entries(data.quotas).forEach(([name, quota]: [string, any]) => {
normalizedQuotas.push(normalizeQuotaEntry(name, quota));
});
}
break;
default:
// Generic fallback for unknown providers
if (data.quotas) {

View File

@@ -43,7 +43,7 @@ interface ProviderConnectionLike {
backoffLevel?: number;
}
const PROVIDER_LIMITS_APIKEY_PROVIDERS = new Set(["glm", "glmt", "minimax", "minimax-cn", "crof"]);
const PROVIDER_LIMITS_APIKEY_PROVIDERS = new Set(["glm", "glmt", "minimax", "minimax-cn", "crof", "nanogpt"]);
const DEFAULT_PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES = 70;
const PROVIDER_LIMITS_AUTO_SYNC_SETTING_KEY = "provider_limits_auto_sync_last_run";

View File

@@ -1846,6 +1846,7 @@ export const USAGE_SUPPORTED_PROVIDERS = [
"minimax",
"minimax-cn",
"crof",
"nanogpt",
];
// ── Zod validation at module load (Phase 7.2) ──

View File

@@ -1308,3 +1308,95 @@ test("usage helper branches cover Gemini CLI and Antigravity plan label fallback
"Custom sky"
);
});
test("usage service covers NanoGPT PRO weekly token quota, FREE plan, auth denial and fetch failures", async () => {
const resetAt = Date.now() + 7 * 24 * 60 * 60 * 1000;
globalThis.fetch = async (url, init = {}) => {
assert.equal(String(url), "https://nano-gpt.com/api/subscription/v1/usage");
assert.equal((init as any).headers.Authorization, "Bearer nanogpt-pro-key");
return new Response(
JSON.stringify({
active: true,
limits: {
weeklyInputTokens: 60_000_000,
dailyInputTokens: null,
dailyImages: 100,
},
dailyInputTokens: null,
weeklyInputTokens: {
used: 31_157_321,
remaining: 28_842_679,
percentUsed: 0.5192886833333333,
resetAt,
},
dailyImages: {
used: 0,
remaining: 100,
percentUsed: 0,
resetAt: Date.now() + 24 * 60 * 60 * 1000,
},
state: "active",
}),
{ status: 200 }
);
};
const proUsage: any = await usageService.getUsageForProvider({
provider: "nanogpt",
apiKey: "nanogpt-pro-key",
});
assert.equal(proUsage.plan, "PRO");
assert.ok(proUsage.quotas["Weekly Tokens"]);
assert.equal(proUsage.quotas["Weekly Tokens"].used, 31_157_321);
assert.equal(proUsage.quotas["Weekly Tokens"].total, 60_000_000);
assert.equal(proUsage.quotas["Weekly Tokens"].remaining, 28_842_679);
assert.ok(proUsage.quotas["Weekly Tokens"].remainingPercentage < 100);
assert.equal(proUsage.quotas["Weekly Tokens"].resetAt, new Date(resetAt).toISOString());
assert.equal(proUsage.quotas["Daily Images"].used, 0);
assert.equal(proUsage.quotas["Daily Images"].remaining, 100);
assert.equal(proUsage.quotas["Daily Images"].remainingPercentage, 100);
globalThis.fetch = async (url, init = {}) => {
assert.equal(String(url), "https://nano-gpt.com/api/subscription/v1/usage");
assert.equal((init as any).headers.Authorization, "Bearer nanogpt-free-key");
return new Response(
JSON.stringify({
active: false,
limits: {},
state: "cancelled",
}),
{ status: 200 }
);
};
const freeUsage: any = await usageService.getUsageForProvider({
provider: "nanogpt",
apiKey: "nanogpt-free-key",
});
assert.equal(freeUsage.plan, "FREE");
assert.deepEqual(freeUsage.quotas, {});
const noKey: any = await usageService.getUsageForProvider({
provider: "nanogpt",
apiKey: "",
});
assert.match(noKey.message, /NanoGPT API key not available/i);
globalThis.fetch = async () => new Response("unauthorized", { status: 401 });
const invalidKey: any = await usageService.getUsageForProvider({
provider: "nanogpt",
apiKey: "nanogpt-bad-key",
});
assert.match(invalidKey.message, /Invalid NanoGPT API key/i);
globalThis.fetch = async () => {
throw new Error("nano-gpt.com unreachable");
};
const fetchError: any = await usageService.getUsageForProvider({
provider: "nanogpt",
apiKey: "nanogpt-fail-key",
});
assert.match(fetchError.message, /Unable to fetch usage: nano-gpt.com unreachable/i);
});