mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat(usage): add NanoGPT subscription quota tracking to Limits & Quotas dashboard (#1865)
Integrated into release/v3.7.8
This commit is contained in:
@@ -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}` };
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -1846,6 +1846,7 @@ export const USAGE_SUPPORTED_PROVIDERS = [
|
||||
"minimax",
|
||||
"minimax-cn",
|
||||
"crof",
|
||||
"nanogpt",
|
||||
];
|
||||
|
||||
// ── Zod validation at module load (Phase 7.2) ──
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user