diff --git a/changelog.d/fixes/moonshot-balance-pct.md b/changelog.d/fixes/moonshot-balance-pct.md new file mode 100644 index 0000000000..c9c044dcad --- /dev/null +++ b/changelog.d/fixes/moonshot-balance-pct.md @@ -0,0 +1 @@ +- **fix(dashboard):** Moonshot/Kimi Open Platform voucher and cash leftover percentages follow the bucket balance, so an empty wallet no longer paints those rows as 100% while Available is 0% diff --git a/open-sse/services/moonshotQuotaFetcher.ts b/open-sse/services/moonshotQuotaFetcher.ts index df80fe11b3..ba44fdc9f2 100644 --- a/open-sse/services/moonshotQuotaFetcher.ts +++ b/open-sse/services/moonshotQuotaFetcher.ts @@ -175,18 +175,15 @@ export async function getMoonshotOpenPlatformUsage( }; } -function balanceQuota( - remaining: number, - remainingPercentage: number, - currency: string -): UsageQuota { +function balanceQuota(remaining: number, currency: string): UsageQuota { + const leftover = remaining > 0 ? 100 : 0; return { used: 0, total: 0, remaining, - remainingPercentage, + remainingPercentage: leftover, resetAt: null, - unlimited: true, + unlimited: false, currency, }; } @@ -196,9 +193,9 @@ function buildMoonshotBalanceQuotas( currency: string ): Record { return { - available: balanceQuota(quota.availableBalance, quota.limitReached ? 0 : 100, currency), - voucher: balanceQuota(quota.voucherBalance, 100, currency), - cash: balanceQuota(quota.cashBalance, 100, currency), + available: balanceQuota(quota.availableBalance, currency), + voucher: balanceQuota(quota.voucherBalance, currency), + cash: balanceQuota(quota.cashBalance, currency), }; } diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts index 7ce822a5d6..5c0616995f 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts @@ -434,7 +434,32 @@ export function isKiloPassDisplayRow(quota: any): boolean { return quota?.kiloPass === true || quota?.name === KILO_PASS_DISPLAY_ROW; } +function parseMoonshotBalanceQuota(quotaKey: string, quota: any) { + if (quotaKey !== "available" && quotaKey !== "voucher" && quotaKey !== "cash") { + return normalizeQuotaEntry(quotaKey, quota); + } + const remaining = Math.max(0, Number(quota?.remaining ?? 0)); + const currency = quota?.currency || "CNY"; + const remainingPercentage = + safePercentage(quota?.remainingPercentage) ?? (remaining > 0 ? 100 : 0); + return buildCreditsQuota(quotaKey, remaining, remainingPercentage, { + currency, + displayName: quota?.displayName, + }); +} + +function parseMoonshotBalance(data: any) { + return quotaEntries(data).map(([quotaKey, quota]) => parseMoonshotBalanceQuota(quotaKey, quota)); +} + +function looksLikeMoonshotBalance(data: any): boolean { + const quotas = data?.quotas; + if (!quotas || typeof quotas !== "object" || Array.isArray(quotas)) return false; + return "available" in quotas && "voucher" in quotas && "cash" in quotas; +} + function parseProviderQuotas(providerId: string, data: any) { + if (looksLikeMoonshotBalance(data)) return parseMoonshotBalance(data); if (providerId === "github") return parseGithub(data); if (["glm", "glm-cn", "glmt", "opencode-go"].includes(providerId)) return parseGlmFamily(data); if (providerId === "antigravity" || providerId === "agy") return parseAntigravity(data); diff --git a/tests/unit/moonshot-quota-dashboard-rendering.test.ts b/tests/unit/moonshot-quota-dashboard-rendering.test.ts new file mode 100644 index 0000000000..4819c58d18 --- /dev/null +++ b/tests/unit/moonshot-quota-dashboard-rendering.test.ts @@ -0,0 +1,87 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { getMoonshotOpenPlatformUsage } from "../../open-sse/services/moonshotQuotaFetcher.ts"; +import { parseQuotaData } from "../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts"; +import { getQuotaRemainingPercentage } from "../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx"; + +const originalFetch = globalThis.fetch; +const CN = "https://api.moonshot.cn/v1"; +const COMPAT = "openai-compatible-chat-e2971611-bc02-4c37-8fc5-39b8e3906fdf"; + +test.afterEach(() => { + globalThis.fetch = originalFetch; +}); + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +/** + * Moonshot Open Platform returns three absolute-balance buckets. The dashboard + * used to paint voucher/cash as 100% leftover even when available_balance is 0, + * because remainingPercentage was hardcoded and parseGeneric never marked the + * rows as credits (so the card showed a percentage, not ¥0.00). + */ +test("empty Moonshot buckets render as 0% leftover, not a fake 100% voucher/cash bar", async () => { + const connectionId = `ms-dash-empty-${Date.now()}`; + globalThis.fetch = async () => + jsonResponse({ + code: 0, + data: { available_balance: 0, voucher_balance: 0, cash_balance: 0 }, + status: true, + }); + + const usage = await getMoonshotOpenPlatformUsage({ + id: connectionId, + provider: COMPAT, + apiKey: "sk-test", + providerSpecificData: { baseUrl: CN }, + }); + const rows = parseQuotaData(COMPAT, usage) as Array<{ + name?: string; + isCredits?: boolean; + remainingPercentage?: number; + creditCount?: number; + currency?: string; + }>; + + const byName = Object.fromEntries(rows.map((row) => [row.name, row])); + for (const name of ["available", "voucher", "cash"] as const) { + const row = byName[name]; + assert.ok(row, `missing ${name} row: ${JSON.stringify(rows)}`); + assert.equal(row.isCredits, true, `${name} must render as a currency row`); + assert.equal(row.currency, "CNY"); + assert.equal(row.creditCount, 0); + assert.equal(row.remainingPercentage, 0); + assert.equal(getQuotaRemainingPercentage(row), 0); + } +}); + +test("Moonshot parse path requires available AND voucher AND cash together", () => { + const steal = parseQuotaData("agentrouter", { + quotas: { + available: { remaining: 7, remainingPercentage: 70, currency: "USD" }, + voucher: { remaining: 1, remainingPercentage: 10, currency: "USD" }, + }, + }) as Array<{ name?: string; isCredits?: boolean; remainingPercentage?: number }>; + const byName = Object.fromEntries(steal.map((row) => [row.name, row])); + assert.equal(byName.available?.isCredits, undefined); + assert.equal(byName.voucher?.isCredits, undefined); + + const full = parseQuotaData("agentrouter", { + quotas: { + available: { remaining: 0, remainingPercentage: 0, currency: "CNY" }, + voucher: { remaining: 0, remainingPercentage: 0, currency: "CNY" }, + cash: { remaining: 0, remainingPercentage: 0, currency: "CNY" }, + }, + }) as Array<{ name?: string; isCredits?: boolean; remainingPercentage?: number }>; + const fullByName = Object.fromEntries(full.map((row) => [row.name, row])); + assert.equal(fullByName.available?.isCredits, true); + assert.equal(fullByName.voucher?.isCredits, true); + assert.equal(fullByName.cash?.isCredits, true); + assert.equal(fullByName.available?.remainingPercentage, 0); +}); diff --git a/tests/unit/moonshot-quota-fetcher.test.ts b/tests/unit/moonshot-quota-fetcher.test.ts index 1c315632b9..24f1e29529 100644 --- a/tests/unit/moonshot-quota-fetcher.test.ts +++ b/tests/unit/moonshot-quota-fetcher.test.ts @@ -137,6 +137,69 @@ test("domestic Moonshot balance is labeled CNY, international USD", async () => invalidateMoonshotQuotaCache(aiId); }); +test("exhausted available_balance does not leave voucher/cash at 100%", async () => { + const connectionId = `ms-empty-pct-${Date.now()}`; + globalThis.fetch = async () => + jsonResponse({ + code: 0, + data: { available_balance: 0, voucher_balance: 0, cash_balance: 0 }, + status: true, + }); + const usage = await getMoonshotOpenPlatformUsage({ + id: connectionId, + provider: COMPAT, + apiKey: "sk-test", + providerSpecificData: { baseUrl: CN }, + }); + assert.equal(usage.quotas?.available?.remaining, 0); + assert.equal(usage.quotas?.available?.remainingPercentage, 0); + assert.equal(usage.quotas?.voucher?.remaining, 0); + assert.equal(usage.quotas?.voucher?.remainingPercentage, 0); + assert.equal(usage.quotas?.cash?.remaining, 0); + assert.equal(usage.quotas?.cash?.remainingPercentage, 0); + invalidateMoonshotQuotaCache(connectionId); +}); + +test("voucher leftover is 100% only when that bucket still has money", async () => { + const connectionId = `ms-voucher-left-${Date.now()}`; + globalThis.fetch = async () => + jsonResponse({ + code: 0, + data: { available_balance: 0, voucher_balance: 5, cash_balance: 0 }, + status: true, + }); + const usage = await getMoonshotOpenPlatformUsage({ + id: connectionId, + provider: COMPAT, + apiKey: "sk-test", + providerSpecificData: { baseUrl: CN }, + }); + assert.equal(usage.quotas?.available?.remainingPercentage, 0); + assert.equal(usage.quotas?.voucher?.remainingPercentage, 100); + assert.equal(usage.quotas?.cash?.remainingPercentage, 0); + invalidateMoonshotQuotaCache(connectionId); +}); + +test("limitReached is derived from availableBalance, so remaining>0 cannot coexist with it", async () => { + const connectionId = `ms-limit-eq-${Date.now()}`; + globalThis.fetch = async () => + jsonResponse({ + code: 0, + data: { available_balance: 2.5, voucher_balance: 0, cash_balance: 2.5 }, + status: true, + }); + const usage = await getMoonshotOpenPlatformUsage({ + id: connectionId, + provider: COMPAT, + apiKey: "sk-test", + providerSpecificData: { baseUrl: CN }, + }); + assert.equal(usage.limitReached, false); + assert.equal(usage.quotas?.available?.remaining, 2.5); + assert.equal(usage.quotas?.available?.remainingPercentage, 100); + invalidateMoonshotQuotaCache(connectionId); +}); + test("registerMoonshotQuotaFetcher wires moonshot and kimi ids", () => { registerMoonshotQuotaFetcher(); assert.equal(typeof getQuotaFetcher("moonshot"), "function");