mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-09 16:32:12 +03:00
fix(dashboard): moonshot voucher/cash leftover follows bucket balance (#12733)
Validado numa worktree combinada com as 16 PRs desta leva sobre `release/v3.8.51`: typecheck:core limpo, check-file-size e check-changelog-integrity OK, complexity 2788/3218 e cognitive 1261/1437 (ambos sob a baseline), ESLint 0 erros nos 152 arquivos alterados, 771 testes unitários focados, 49 de integração e a suíte vitest:ui completa (2149) verdes. Available 0% com Voucher 100% e Cash 100% não é estado de carteira que exista — foi o sinal certo para puxar o fio. Tratar leftover como booleano só para Available e cravar 100% nos outros dois buckets é o tipo de defeito que passa despercebido enquanto a conta tem saldo. Os dois testes cobrem os dois lados: o produtor e o caminho até `getQuotaRemainingPercentage` com `isCredits` + CNY.
This commit is contained in:
1
changelog.d/fixes/moonshot-balance-pct.md
Normal file
1
changelog.d/fixes/moonshot-balance-pct.md
Normal file
@@ -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%
|
||||
@@ -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<string, UsageQuota> {
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
87
tests/unit/moonshot-quota-dashboard-rendering.test.ts
Normal file
87
tests/unit/moonshot-quota-dashboard-rendering.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user