diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index 9358333a1b..5728e99eea 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -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 = {}; + + // 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}` }; } diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx index 7e77aa83b8..e58e92a955 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx @@ -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) diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx index 929918d9e1..ddce524a9f 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx @@ -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) { diff --git a/src/lib/usage/providerLimits.ts b/src/lib/usage/providerLimits.ts index d64b851d6d..5855c04bab 100644 --- a/src/lib/usage/providerLimits.ts +++ b/src/lib/usage/providerLimits.ts @@ -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"; diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 2f68c7cc0b..85b7c4f9e3 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -1846,6 +1846,7 @@ export const USAGE_SUPPORTED_PROVIDERS = [ "minimax", "minimax-cn", "crof", + "nanogpt", ]; // ── Zod validation at module load (Phase 7.2) ── diff --git a/tests/unit/usage-service-hardening.test.ts b/tests/unit/usage-service-hardening.test.ts index 7253465568..8642c6564b 100644 --- a/tests/unit/usage-service-hardening.test.ts +++ b/tests/unit/usage-service-hardening.test.ts @@ -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); +});