From 82e5afed6b528acfda29841c01b8e05ee593591e Mon Sep 17 00:00:00 2001 From: Xiangzhe <32761048+xz-dev@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:28:37 +0800 Subject: [PATCH] feat(usage): show Kimi Coding Extra Usage (#10712) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged via merge-train (release/v3.8.50, batch1 2026-08-20) — static gates (typecheck/file-size/complexity/cognitive/changelog) green on the combined tree; test:unit reds observed in the boarded run were verified pre-existing on the pure release tip (unrelated flake), not caused by this PR. Thanks for the contribution! --- .../features/kimi-coding-extra-usage.md | 1 + open-sse/services/usage/kimi.ts | 211 +++++++-- .../components/ProviderLimits/QuotaCard.tsx | 13 +- .../parts/QuotaCardExpanded.tsx | 34 +- .../components/ProviderLimits/quotaParsing.ts | 21 +- .../usage/components/ProviderLimits/utils.tsx | 4 +- src/i18n/messages/en.json | 10 + src/i18n/messages/pt-BR.json | 10 + src/i18n/messages/vi.json | 10 + src/i18n/messages/zh-CN.json | 10 + src/i18n/messages/zh-TW.json | 10 + src/lib/db/providerLimits.ts | 11 +- src/lib/usage/providerLimitsCache.ts | 18 +- src/shared/utils/kimiBilling.ts | 199 +++++++++ src/shared/utils/providerBilling.ts | 36 ++ tests/unit/kimi-coding-billing-ui.test.ts | 159 +++++++ tests/unit/kimi-coding-billing.test.ts | 419 ++++++++++++++++++ ...ota-card-expanded-fixed-order-6687.test.ts | 53 +++ .../repro-7764-collapsed-quota-order.test.ts | 18 + tests/unit/usage-service-hardening.test.ts | 5 +- 20 files changed, 1181 insertions(+), 71 deletions(-) create mode 100644 changelog.d/features/kimi-coding-extra-usage.md create mode 100644 src/shared/utils/kimiBilling.ts create mode 100644 src/shared/utils/providerBilling.ts create mode 100644 tests/unit/kimi-coding-billing-ui.test.ts create mode 100644 tests/unit/kimi-coding-billing.test.ts diff --git a/changelog.d/features/kimi-coding-extra-usage.md b/changelog.d/features/kimi-coding-extra-usage.md new file mode 100644 index 0000000000..766ec1020c --- /dev/null +++ b/changelog.d/features/kimi-coding-extra-usage.md @@ -0,0 +1 @@ +- **feat(usage):** show Kimi Coding's fixed-order Code 5-hour/7-day quota windows plus Extra Usage status, balance, monthly spend/limit, and the official Additional Credits link on Dashboard → Quota cards. diff --git a/open-sse/services/usage/kimi.ts b/open-sse/services/usage/kimi.ts index ca9f2d5630..d27c3c889b 100644 --- a/open-sse/services/usage/kimi.ts +++ b/open-sse/services/usage/kimi.ts @@ -9,12 +9,16 @@ */ import { safePercentage } from "@/shared/utils/formatting"; +import { + KIMI_CODE_ADDITIONAL_CREDITS_URL, + type KimiBillingStatus, +} from "@/shared/utils/kimiBilling"; import { buildKimiCodeIdentityHeaders, getKimiCodeCliUserAgent, } from "../../config/providers/registry/kimi/coding/runtime.ts"; import { toRecord, toNumber } from "./scalars.ts"; -import { type UsageQuota, parseResetTime } from "./quota.ts"; +import { createQuotaFromUsage, type UsageQuota, parseResetTime } from "./quota.ts"; type JsonRecord = Record; @@ -25,6 +29,145 @@ const KIMI_CONFIG = { apiVersion: "2023-06-01", }; +const KIMI_BOOSTER_FIXED_POINT_PER_CENT = 1_000_000; + +function toInteger(value: unknown): number | null { + const parsed = toNumber(value, Number.NaN); + return Number.isFinite(parsed) ? Math.trunc(parsed) : null; +} + +function fixedPointToCents(value: number): number { + const cents = value / KIMI_BOOSTER_FIXED_POINT_PER_CENT; + if (cents > 0 && cents < 1) return 1; + return Math.round(cents); +} + +function parseKimiMoney(value: unknown): { cents: number; currency: string } | null { + const money = toRecord(value); + const cents = toInteger(money.priceInCents); + const currency = money.currency; + if ( + cents === null || + cents < 0 || + typeof currency !== "string" || + !/^[A-Za-z]{3}$/.test(currency) + ) { + return null; + } + return { cents, currency: currency.toUpperCase() }; +} + +function parseKimiExtraUsageStatus(value: unknown): KimiBillingStatus["extraUsageStatus"] { + switch (value) { + case "STATUS_ACTIVE": + return "enabled"; + case "STATUS_DISABLED": + return "disabled"; + case "STATUS_FROZEN": + return "frozen"; + default: + return "unavailable"; + } +} + +function parseKimiBoosterWallet(value: unknown): KimiBillingStatus | null { + const wallet = toRecord(value); + const balance = toRecord(wallet.balance); + if (balance.type !== "BOOSTER") return null; + + const amount = toInteger(balance.amount); + const amountLeft = toInteger(balance.amountLeft); + const monthlyLimit = parseKimiMoney(wallet.monthlyChargeLimit); + const monthlyUsed = parseKimiMoney(wallet.monthlyUsed); + const autoRefillCharge = parseKimiMoney(wallet.autoRefillCharge); + const autoRefillThreshold = parseKimiMoney(wallet.autoRefillThreshold); + const extraUsageStatus = parseKimiExtraUsageStatus(wallet.status); + const hasWalletEvidence = + (amount !== null && amount > 0) || + amountLeft !== null || + monthlyLimit !== null || + monthlyUsed !== null || + extraUsageStatus !== "unavailable"; + if (!hasWalletEvidence) return null; + + const currency = + monthlyLimit?.currency ?? + monthlyUsed?.currency ?? + autoRefillCharge?.currency ?? + autoRefillThreshold?.currency ?? + "USD"; + + return { + currency, + // Proto JSON omits numeric zero values. Production therefore returns a + // BOOSTER balance record without amount/amountLeft when the preserved + // balance is exactly zero; treat that as an explicit zero, not unknown. + extraCreditsMinorUnits: + amountLeft === null || amountLeft < 0 ? 0 : fixedPointToCents(amountLeft), + monthlyUsedMinorUnits: monthlyUsed?.cents ?? 0, + monthlyLimitEnabled: wallet.monthlyChargeLimitEnabled === true, + monthlyLimitMinorUnits: monthlyLimit?.cents ?? 0, + extraUsageStatus, + additionalCreditsUrl: KIMI_CODE_ADDITIONAL_CREDITS_URL, + }; +} + +function buildKimiBillingStatus(value: unknown): KimiBillingStatus { + return ( + parseKimiBoosterWallet(value) ?? { + currency: "USD", + extraUsageStatus: "unavailable", + additionalCreditsUrl: KIMI_CODE_ADDITIONAL_CREDITS_URL, + } + ); +} + +function optionalNumber(value: unknown): number | null { + if (typeof value !== "number" && typeof value !== "string") return null; + const parsed = toNumber(value, Number.NaN); + return Number.isFinite(parsed) ? parsed : null; +} + +function createKimiCountQuota(value: unknown): UsageQuota | null { + const detail = toRecord(value); + const limit = optionalNumber(detail.limit ?? detail.Limit); + if (limit === null || limit <= 0) return null; + + const reportedUsed = optionalNumber(detail.used ?? detail.Used); + const reportedRemaining = optionalNumber(detail.remaining ?? detail.Remaining); + const used = reportedUsed ?? (reportedRemaining === null ? 0 : limit - reportedRemaining); + return createQuotaFromUsage(used, limit, detail.resetTime ?? detail.reset_at ?? detail.resetAt); +} + +type KimiWindowLabel = { key: string; displayName: string }; + +function normalizeKimiWindow(value: unknown, fallbackIndex: number): KimiWindowLabel { + const window = toRecord(value); + const duration = optionalNumber(window.duration); + const timeUnit = window.timeUnit; + + if (duration !== null && duration > 0) { + if (timeUnit === "TIME_UNIT_MINUTE" && duration % 60 === 0) { + const hours = duration / 60; + return { key: `${hours}h`, displayName: `Code · ${hours}h` }; + } + if (timeUnit === "TIME_UNIT_HOUR") { + return { key: `${duration}h`, displayName: `Code · ${duration}h` }; + } + if (timeUnit === "TIME_UNIT_DAY") { + return { key: `${duration}d`, displayName: `Code · ${duration}d` }; + } + if (timeUnit === "TIME_UNIT_WEEK") { + return { key: `${duration}w`, displayName: `Code · ${duration}w` }; + } + if (timeUnit === "TIME_UNIT_MINUTE") { + return { key: `${duration}m`, displayName: `Code · ${duration}m` }; + } + } + + return { key: `limit_${fallbackIndex}`, displayName: `Code · Limit ${fallbackIndex}` }; +} + /** * Map Kimi membership level to display name * LEVEL_BASIC = Moderato, LEVEL_INTERMEDIATE = Allegretto, @@ -100,52 +243,38 @@ export async function getKimiUsage( const quotas: Record = {}; const dataObj = toRecord(data); + const billing = buildKimiBillingStatus(dataObj.boosterWallet); - // Parse Kimi usage response format - // Format: { user: {...}, usage: { limit: "100", used: "92", remaining: "8", resetTime: "..." }, limits: [...] } - const usageObj = toRecord(dataObj.usage); - - // Check for Kimi's actual usage fields (strings, not numbers) - const usageLimit = toNumber(usageObj.limit || usageObj.Limit, 0); - const usageUsed = toNumber(usageObj.used || usageObj.Used, 0); - const usageRemaining = toNumber(usageObj.remaining || usageObj.Remaining, 0); - const usageResetTime = - usageObj.resetTime || usageObj.ResetTime || usageObj.reset_at || usageObj.resetAt; - - if (usageLimit > 0) { - const percentRemaining = usageLimit > 0 ? (usageRemaining / usageLimit) * 100 : 0; - - quotas["Weekly"] = { - used: usageUsed, - total: usageLimit, - remaining: usageRemaining, - remainingPercentage: percentRemaining, - resetAt: parseResetTime(usageResetTime), - unlimited: false, - }; + // The managed Kimi Code API reports the Code 7-day quota in `usage`. + // The website's separate shared-membership total/Kimi split comes from a + // Web-session-only endpoint and cannot be read with a Coding OAuth token. + const weeklyQuota = createKimiCountQuota(dataObj.usage); + if (weeklyQuota) { + quotas.code_7d = { ...weeklyQuota, displayName: "Code · 7d" }; } - // Also parse limits array for rate limits + // Each limits[] item is an independent rolling window. Preserve all of + // them with deterministic window-derived keys instead of overwriting one + // generic `Ratelimit` row. const limitsArray = Array.isArray(dataObj.limits) ? dataObj.limits : []; for (let i = 0; i < limitsArray.length; i++) { const limitItem = toRecord(limitsArray[i]); - const window = toRecord(limitItem.window); - const detail = toRecord(limitItem.detail); + const quota = createKimiCountQuota(limitItem.detail); + if (!quota) continue; - const limit = toNumber(detail.limit || detail.Limit, 0); - const remaining = toNumber(detail.remaining || detail.Remaining, 0); - const resetTime = detail.resetTime || detail.reset_at || detail.resetAt; - - if (limit > 0) { - quotas["Ratelimit"] = { - used: limit - remaining, - total: limit, - remaining, - remainingPercentage: limit > 0 ? (remaining / limit) * 100 : 0, - resetAt: parseResetTime(resetTime), - unlimited: false, - }; - } + const normalized = normalizeKimiWindow(limitItem.window, i + 1); + const baseKey = `code_${normalized.key}`; + let key = baseKey; + let suffix = 2; + while (key in quotas) key = `${baseKey}_${suffix++}`; + const reportedName = + typeof limitItem.name === "string" && limitItem.name.trim() ? limitItem.name.trim() : null; + const displayName = reportedName + ? /^code\b/i.test(reportedName) + ? reportedName + : `Code · ${reportedName}` + : normalized.displayName; + quotas[key] = { ...quota, displayName }; } // Check for quota windows (Claude-like format with utilization) as fallback @@ -189,6 +318,7 @@ export async function getKimiUsage( return { plan: planName || "Kimi Coding", quotas, + billing, }; } @@ -199,6 +329,7 @@ export async function getKimiUsage( return { plan: planName || "Kimi Coding", message: "Kimi Coding connected. Usage tracked per request.", + billing, }; } catch (error) { return { diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCard.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCard.tsx index 7859c0b008..af76b84a77 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCard.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCard.tsx @@ -2,7 +2,10 @@ import { useMemo, useState } from "react"; import Card from "@/shared/components/Card"; -import type { GrokBillingStatus } from "@/shared/utils/grokBilling"; +import { + isProviderBillingProvider, + type ProviderBillingStatus, +} from "@/shared/utils/providerBilling"; import { pickDisplayValue } from "@/shared/utils/maskEmail"; import { normalizePlanTier, @@ -35,8 +38,8 @@ interface QuotaCardProps { quotas?: any[]; plan?: string | null; message?: string | null; - billing?: GrokBillingStatus | null; - raw?: { billing?: GrokBillingStatus | null }; + billing?: ProviderBillingStatus | null; + raw?: { billing?: ProviderBillingStatus | null }; stale?: { since?: string; reason?: string } | null; } | undefined; @@ -151,7 +154,9 @@ export default function QuotaCard({ error={error} message={quota?.message ?? null} billing={ - connection.provider === "grok-cli" ? (quota?.billing ?? quota?.raw?.billing) : null + isProviderBillingProvider(connection.provider) + ? (quota?.billing ?? quota?.raw?.billing) + : null } refreshedAt={displayRefreshedAt} hasStaleData={hasStaleData} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardExpanded.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardExpanded.tsx index 25e47a8741..60346dc03b 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardExpanded.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardExpanded.tsx @@ -2,7 +2,13 @@ import { useMemo, useState } from "react"; import { useLocale, useTranslations } from "next-intl"; -import { buildGrokBillingCardRows, type GrokBillingStatus } from "@/shared/utils/grokBilling"; +import { buildGrokBillingCardRows } from "@/shared/utils/grokBilling"; +import { buildKimiBillingCardRows } from "@/shared/utils/kimiBilling"; +import { + isKimiBillingStatus, + isProviderBillingProvider, + type ProviderBillingStatus, +} from "@/shared/utils/providerBilling"; import { formatCountdown, formatQuotaLabel, @@ -27,19 +33,23 @@ const CURRENCY_SYMBOLS: Record = { const DEFAULT_VISIBLE_ROWS = 3; -function GrokBillingDetails({ billing }: { billing: GrokBillingStatus }) { +function ProviderBillingDetails({ billing }: { billing: ProviderBillingStatus }) { const t = useTranslations("usage"); const locale = useLocale(); - const rows = buildGrokBillingCardRows(billing, locale, (key, fallback) => - translateUsageOrFallback(t, key, fallback) - ); + const rows = isKimiBillingStatus(billing) + ? buildKimiBillingCardRows(billing, locale, (key, fallback) => + translateUsageOrFallback(t, key, fallback) + ) + : buildGrokBillingCardRows(billing, locale, (key, fallback) => + translateUsageOrFallback(t, key, fallback) + ); return (
{rows.map((row) => row.kind === "link" ? ( ) : (
void; @@ -357,7 +367,9 @@ export default function QuotaCardExpanded({
)} - {providerId === "grok-cli" && billing && } + {isProviderBillingProvider(providerId) && billing && ( + + )} {hiddenQuotaRows.length > 0 && (
diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts index 3aea687442..63977b1d12 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts @@ -10,15 +10,16 @@ const CODEX_QUOTA_ORDER: Record = { banked_reset_credits: 4, }; const GLM_FAMILY_PROVIDERS = ["glm", "glm-cn", "glmt", "opencode-go"]; +const KIMI_CODING_PROVIDERS = ["kimi-coding", "kimi-coding-apikey"]; /** - * Providers whose quotas already get a deterministic fixed-window order from - * sortGlmOrder()/sortCodexOrder() below. Display layers (e.g. QuotaCardExpanded) + * Providers whose quotas already get a deterministic fixed-window order below + * (Codex, GLM family, and Kimi Coding). Display layers (e.g. QuotaCardExpanded) * must not re-sort these by remaining percentage, or they undo this order (#6687). */ export function hasFixedQuotaOrder(providerId: string | undefined): boolean { const id = String(providerId || "").toLowerCase(); - return id === "codex" || GLM_FAMILY_PROVIDERS.includes(id); + return id === "codex" || GLM_FAMILY_PROVIDERS.includes(id) || KIMI_CODING_PROVIDERS.includes(id); } function quotaEntries(data: any): Array<[string, any]> { @@ -269,6 +270,19 @@ function sortCodexOrder(providerId: string, quotas: any[]) { quotas.sort((a, b) => (CODEX_QUOTA_ORDER[a.name] ?? 99) - (CODEX_QUOTA_ORDER[b.name] ?? 99)); } +function sortKimiOrder(providerId: string, quotas: any[]) { + if (!KIMI_CODING_PROVIDERS.includes(providerId)) return; + const rank = (name: string) => { + if (/^code_5h(?:_|$)/.test(name)) return 0; + if (/^code_7d(?:_|$)/.test(name)) return 1; + return 99; + }; + quotas.sort((a, b) => { + const rankDiff = rank(String(a.name)) - rank(String(b.name)); + return rankDiff || String(a.name).localeCompare(String(b.name)); + }); +} + export function parseQuotaData(provider: string | undefined, data: any) { if (!data || typeof data !== "object") return []; const providerId = String(provider || "").toLowerCase(); @@ -278,6 +292,7 @@ export function parseQuotaData(provider: string | undefined, data: any) { sortProviderModelOrder(provider, normalizedQuotas); sortGlmOrder(providerId, normalizedQuotas); sortCodexOrder(providerId, normalizedQuotas); + sortKimiOrder(providerId, normalizedQuotas); return normalizedQuotas; } catch (error) { console.error(`Error parsing quota data for ${provider}:`, error); diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx index add21b4e17..3af32da285 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx @@ -392,8 +392,8 @@ const STATUS_ORDER: Record<"critical" | "alert" | "ok", number> = { export function topQuotas(quotas: any[], n = 3, providerId?: string): any[] { const filtered = quotas.filter(Boolean); - // Providers with a deterministic fixed-window order (codex, glm family — see - // quotaParsing.ts's sortCodexOrder()/sortGlmOrder()) must keep the order + // Providers with a deterministic fixed-window order (Codex, GLM family, + // Kimi Coding — see quotaParsing.ts) must keep the order // parseQuotaData() already established rather than being re-sorted by // status/remaining-%, which would undo it (#6687's collapsed-card sibling, #7764). if (hasFixedQuotaOrder(providerId)) { diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index f8683269c4..4a67320eab 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -9098,6 +9098,16 @@ "grokAutoTopUpMax": "max", "grokAutoTopUpMonth": "month", "grokAdditionalCredits": "Additional Credits", + "kimiExtraUsageCredits": "Extra Usage Credits", + "kimiExtraUsage": "Extra Usage", + "kimiExtraUsageEnabled": "Enabled", + "kimiExtraUsageDisabled": "Disabled", + "kimiExtraUsageFrozen": "Frozen", + "kimiExtraUsageUnavailable": "Unavailable", + "kimiMonthlyUsed": "Used this month", + "kimiMonthlyLimit": "Monthly limit", + "kimiMonthlyLimitUnlimited": "Unlimited", + "kimiAdditionalCredits": "Additional Credits", "loggerTab": "Logger", "proxyTab": "Proxy", "budgetManagement": "Budget Management", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index 0d79c2a0d2..ef36e19471 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -9090,6 +9090,16 @@ "grokAutoTopUpMax": "máximo", "grokAutoTopUpMonth": "mês", "grokAdditionalCredits": "Créditos adicionais", + "kimiExtraUsageCredits": "Créditos de uso extra", + "kimiExtraUsage": "Uso extra", + "kimiExtraUsageEnabled": "Ativado", + "kimiExtraUsageDisabled": "Desativado", + "kimiExtraUsageFrozen": "Congelado", + "kimiExtraUsageUnavailable": "Indisponível", + "kimiMonthlyUsed": "Usado neste mês", + "kimiMonthlyLimit": "Limite mensal", + "kimiMonthlyLimitUnlimited": "Ilimitado", + "kimiAdditionalCredits": "Créditos adicionais", "loggerTab": "Logger", "proxyTab": "Proxy", "budgetManagement": "Gerenciamento de Orçamento", diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json index 16dece1256..1691040f80 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -9098,6 +9098,16 @@ "grokAutoTopUpMax": "tối đa", "grokAutoTopUpMonth": "tháng", "grokAdditionalCredits": "Tín dụng bổ sung", + "kimiExtraUsageCredits": "Tín dụng sử dụng bổ sung", + "kimiExtraUsage": "Sử dụng bổ sung", + "kimiExtraUsageEnabled": "Đã bật", + "kimiExtraUsageDisabled": "Đã tắt", + "kimiExtraUsageFrozen": "Đã đóng băng", + "kimiExtraUsageUnavailable": "Không khả dụng", + "kimiMonthlyUsed": "Đã dùng trong tháng này", + "kimiMonthlyLimit": "Giới hạn hàng tháng", + "kimiMonthlyLimitUnlimited": "Không giới hạn", + "kimiAdditionalCredits": "Tín dụng bổ sung", "loggerTab": "Logger", "proxyTab": "Proxy", "budgetManagement": "Quản lý ngân sách", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index f4b62520a6..18a1ea316f 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -9077,6 +9077,16 @@ "grokAutoTopUpMax": "最大", "grokAutoTopUpMonth": "月", "grokAdditionalCredits": "额外的致谢", + "kimiExtraUsageCredits": "加油包余额", + "kimiExtraUsage": "额度加油包", + "kimiExtraUsageEnabled": "已开启", + "kimiExtraUsageDisabled": "已关闭", + "kimiExtraUsageFrozen": "已冻结", + "kimiExtraUsageUnavailable": "不可用", + "kimiMonthlyUsed": "本月已用", + "kimiMonthlyLimit": "每月限额", + "kimiMonthlyLimitUnlimited": "无限制", + "kimiAdditionalCredits": "充值加油包", "loggerTab": "记录器", "proxyTab": "代理", "budgetManagement": "预算管理", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index a7ac344ae8..7465d3e9f6 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -9077,6 +9077,16 @@ "grokAutoTopUpMax": "最大", "grokAutoTopUpMonth": "月份", "grokAdditionalCredits": "額外的致謝", + "kimiExtraUsageCredits": "加油包餘額", + "kimiExtraUsage": "額度加油包", + "kimiExtraUsageEnabled": "已開啟", + "kimiExtraUsageDisabled": "已關閉", + "kimiExtraUsageFrozen": "已凍結", + "kimiExtraUsageUnavailable": "無法使用", + "kimiMonthlyUsed": "本月已用", + "kimiMonthlyLimit": "每月限額", + "kimiMonthlyLimitUnlimited": "無限制", + "kimiAdditionalCredits": "儲值加油包", "loggerTab": "記錄器", "proxyTab": "代理", "budgetManagement": "預算管理", diff --git a/src/lib/db/providerLimits.ts b/src/lib/db/providerLimits.ts index 427cc4a1ef..677c067a6f 100644 --- a/src/lib/db/providerLimits.ts +++ b/src/lib/db/providerLimits.ts @@ -1,4 +1,7 @@ -import { sanitizeGrokBillingStatus, type GrokBillingStatus } from "@/shared/utils/grokBilling"; +import { + sanitizeProviderBillingStatus, + type ProviderBillingStatus, +} from "@/shared/utils/providerBilling"; import { getDbInstance, isBuildPhase, isCloud } from "./core"; type JsonRecord = Record; @@ -26,7 +29,7 @@ export interface ProviderLimitsCacheEntry { fetchedAt: string; source?: string | null; bankedResetCredits?: number; - billing?: GrokBillingStatus; + billing?: ProviderBillingStatus; } const PROVIDER_LIMITS_CACHE_NAMESPACE = "providerLimitsCache"; @@ -45,7 +48,7 @@ function toRecord(value: unknown): JsonRecord | null { function sanitizeCacheEntryForStorage(entry: ProviderLimitsCacheEntry): ProviderLimitsCacheEntry { const { billing: rawBilling, ...rest } = entry; - const billing = sanitizeGrokBillingStatus(rawBilling); + const billing = sanitizeProviderBillingStatus(rawBilling); return billing ? { ...rest, billing } : rest; } @@ -58,7 +61,7 @@ function normalizeCacheEntry(value: unknown): ProviderLimitsCacheEntry | null { if (!fetchedAt) return null; const bankedResetCredits = Number(record.bankedResetCredits); - const billing = sanitizeGrokBillingStatus(record.billing); + const billing = sanitizeProviderBillingStatus(record.billing); return { quotas: toRecord(record.quotas), diff --git a/src/lib/usage/providerLimitsCache.ts b/src/lib/usage/providerLimitsCache.ts index 75fd031057..6548bef54b 100644 --- a/src/lib/usage/providerLimitsCache.ts +++ b/src/lib/usage/providerLimitsCache.ts @@ -1,5 +1,6 @@ import type { ProviderLimitsCacheEntry } from "@/lib/db/providerLimits"; -import { sanitizeGrokBillingStatus } from "@/shared/utils/grokBilling"; +import { sanitizeProviderBillingStatus } from "@/shared/utils/providerBilling"; +import { GROK_BUILD_ADDITIONAL_CREDITS_URL } from "@/shared/utils/grokBilling"; const GROK_CLI_PROVIDER = "grok-cli"; @@ -26,7 +27,7 @@ export function toProviderLimitsCacheEntry( fetchedAt, source, bankedResetCredits: Number.isFinite(bankedResetCredits) ? bankedResetCredits : undefined, - billing: sanitizeGrokBillingStatus(usage.billing), + billing: sanitizeProviderBillingStatus(usage.billing), }; } @@ -44,14 +45,21 @@ export function mergeProviderLimitsCacheEntry( if (provider !== GROK_CLI_PROVIDER) return next; const nextBilling = next.billing; - const previousAutoTopUp = previous.billing?.autoTopUp; - if (!nextBilling || nextBilling.autoTopUp.available || !previousAutoTopUp) return next; + const previousBilling = previous.billing; + if ( + !nextBilling || + nextBilling.additionalCreditsUrl !== GROK_BUILD_ADDITIONAL_CREDITS_URL || + nextBilling.autoTopUp.available || + !previousBilling || + previousBilling.additionalCreditsUrl !== GROK_BUILD_ADDITIONAL_CREDITS_URL + ) + return next; return { ...next, billing: { ...nextBilling, - autoTopUp: previousAutoTopUp, + autoTopUp: previousBilling.autoTopUp, }, }; } diff --git a/src/shared/utils/kimiBilling.ts b/src/shared/utils/kimiBilling.ts new file mode 100644 index 0000000000..c818a675aa --- /dev/null +++ b/src/shared/utils/kimiBilling.ts @@ -0,0 +1,199 @@ +/** + * Public Dashboard contract for Kimi Coding Extra Usage (额度加油包). + * + * The existing read-only `GET /coding/v1/usages` response carries both the + * Code quota windows and `boosterWallet`. Only the strictly whitelisted fields + * below may cross the Provider Limits cache/UI boundary. + */ + +export const KIMI_CODE_ADDITIONAL_CREDITS_URL = + "https://www.kimi.com/membership/subscription?tab=quota&aff=omniroute"; + +type KimiExtraUsageStatus = "enabled" | "disabled" | "frozen" | "unavailable"; + +export interface KimiBillingStatus { + /** ISO 4217 currency reported by the wallet money wrappers. */ + currency: string; + /** Remaining Extra Usage balance in cents. */ + extraCreditsMinorUnits?: number; + /** Extra Usage spend so far this calendar month, in cents. */ + monthlyUsedMinorUnits?: number; + /** Whether the member enabled a monthly spending cap. */ + monthlyLimitEnabled?: boolean; + /** Monthly spending cap in cents; 0/absent means unlimited. */ + monthlyLimitMinorUnits?: number; + extraUsageStatus: KimiExtraUsageStatus; + additionalCreditsUrl: typeof KIMI_CODE_ADDITIONAL_CREDITS_URL; +} + +type KimiBillingTranslationKey = + | "kimiExtraUsageCredits" + | "kimiExtraUsage" + | "kimiExtraUsageEnabled" + | "kimiExtraUsageDisabled" + | "kimiExtraUsageFrozen" + | "kimiExtraUsageUnavailable" + | "kimiMonthlyUsed" + | "kimiMonthlyLimit" + | "kimiMonthlyLimitUnlimited" + | "kimiAdditionalCredits"; + +type KimiBillingTranslator = (key: KimiBillingTranslationKey, fallback: string) => string; + +type KimiBillingCardRow = + | { kind: "balance" | "status"; label: string; value: string } + | { + kind: "link"; + label: string; + href: typeof KIMI_CODE_ADDITIONAL_CREDITS_URL; + target: "_blank"; + rel: "noreferrer noopener"; + }; + +type JsonRecord = Record; + +function toRecord(value: unknown): JsonRecord | null { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : null; +} + +function minorUnits(value: unknown): number | undefined { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined; +} + +const ISO_4217 = /^[A-Za-z]{3}$/; +const EXTRA_USAGE_STATUSES = new Set([ + "enabled", + "disabled", + "frozen", + "unavailable", +]); + +export function sanitizeKimiBillingStatus(value: unknown): KimiBillingStatus | undefined { + const billing = toRecord(value); + if (!billing || billing.additionalCreditsUrl !== KIMI_CODE_ADDITIONAL_CREDITS_URL) + return undefined; + + const currency = + typeof billing.currency === "string" && ISO_4217.test(billing.currency) + ? billing.currency.toUpperCase() + : undefined; + const extraUsageStatus = + typeof billing.extraUsageStatus === "string" && + EXTRA_USAGE_STATUSES.has(billing.extraUsageStatus as KimiExtraUsageStatus) + ? (billing.extraUsageStatus as KimiExtraUsageStatus) + : undefined; + if (!currency || !extraUsageStatus) return undefined; + + const extraCreditsMinorUnits = minorUnits(billing.extraCreditsMinorUnits); + const monthlyUsedMinorUnits = minorUnits(billing.monthlyUsedMinorUnits); + const monthlyLimitMinorUnits = minorUnits(billing.monthlyLimitMinorUnits); + const monthlyLimitEnabled = + typeof billing.monthlyLimitEnabled === "boolean" ? billing.monthlyLimitEnabled : undefined; + + return { + currency, + ...(extraCreditsMinorUnits !== undefined ? { extraCreditsMinorUnits } : {}), + ...(monthlyUsedMinorUnits !== undefined ? { monthlyUsedMinorUnits } : {}), + ...(monthlyLimitEnabled !== undefined ? { monthlyLimitEnabled } : {}), + ...(monthlyLimitMinorUnits !== undefined ? { monthlyLimitMinorUnits } : {}), + extraUsageStatus, + additionalCreditsUrl: KIMI_CODE_ADDITIONAL_CREDITS_URL, + }; +} + +function formatKimiMinorUnits( + value: number | undefined, + currency: KimiBillingStatus["currency"], + locales?: Intl.LocalesArgument +): string | null { + if (value === undefined) return null; + return new Intl.NumberFormat(locales, { + style: "currency", + currency, + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(value / 100); +} + +const fallbackTranslation: KimiBillingTranslator = (_key, fallback) => fallback; + +function formatExtraUsageStatus( + status: KimiExtraUsageStatus, + translate: KimiBillingTranslator +): string { + switch (status) { + case "enabled": + return translate("kimiExtraUsageEnabled", "Enabled"); + case "disabled": + return translate("kimiExtraUsageDisabled", "Disabled"); + case "frozen": + return translate("kimiExtraUsageFrozen", "Frozen"); + default: + return translate("kimiExtraUsageUnavailable", "Unavailable"); + } +} + +export function buildKimiBillingCardRows( + billing: KimiBillingStatus, + locales?: Intl.LocalesArgument, + translate: KimiBillingTranslator = fallbackTranslation +): KimiBillingCardRow[] { + const rows: KimiBillingCardRow[] = []; + const walletPresent = billing.extraCreditsMinorUnits !== undefined; + + const extraCredits = formatKimiMinorUnits( + billing.extraCreditsMinorUnits, + billing.currency, + locales + ); + if (extraCredits !== null) { + rows.push({ + kind: "balance", + label: translate("kimiExtraUsageCredits", "Extra Usage Credits"), + value: extraCredits, + }); + } + + rows.push({ + kind: "status", + label: translate("kimiExtraUsage", "Extra Usage"), + value: formatExtraUsageStatus(billing.extraUsageStatus, translate), + }); + + if (walletPresent) { + const monthlyUsed = formatKimiMinorUnits( + billing.monthlyUsedMinorUnits, + billing.currency, + locales + ); + if (monthlyUsed !== null) { + rows.push({ + kind: "status", + label: translate("kimiMonthlyUsed", "Used this month"), + value: monthlyUsed, + }); + } + + const capped = + billing.monthlyLimitEnabled === true && + billing.monthlyLimitMinorUnits !== undefined && + billing.monthlyLimitMinorUnits > 0; + const monthlyLimit = capped + ? formatKimiMinorUnits(billing.monthlyLimitMinorUnits, billing.currency, locales) + : null; + rows.push({ + kind: "status", + label: translate("kimiMonthlyLimit", "Monthly limit"), + value: monthlyLimit ?? translate("kimiMonthlyLimitUnlimited", "Unlimited"), + }); + } + + rows.push({ + kind: "link", + label: translate("kimiAdditionalCredits", "Additional Credits"), + href: billing.additionalCreditsUrl, + target: "_blank", + rel: "noreferrer noopener", + }); + return rows; +} diff --git a/src/shared/utils/providerBilling.ts b/src/shared/utils/providerBilling.ts new file mode 100644 index 0000000000..f2ddca4dee --- /dev/null +++ b/src/shared/utils/providerBilling.ts @@ -0,0 +1,36 @@ +import { + GROK_BUILD_ADDITIONAL_CREDITS_URL, + sanitizeGrokBillingStatus, + type GrokBillingStatus, +} from "./grokBilling"; +import { + KIMI_CODE_ADDITIONAL_CREDITS_URL, + sanitizeKimiBillingStatus, + type KimiBillingStatus, +} from "./kimiBilling"; + +export type ProviderBillingStatus = GrokBillingStatus | KimiBillingStatus; + +export const PROVIDER_BILLING_PROVIDERS = [ + "grok-cli", + "kimi-coding", + "kimi-coding-apikey", +] as const; + +export function isProviderBillingProvider(provider: string | undefined): boolean { + return ( + provider !== undefined && (PROVIDER_BILLING_PROVIDERS as readonly string[]).includes(provider) + ); +} + +export function sanitizeProviderBillingStatus(value: unknown): ProviderBillingStatus | undefined { + return sanitizeGrokBillingStatus(value) ?? sanitizeKimiBillingStatus(value); +} + +export function isGrokBillingStatus(billing: ProviderBillingStatus): billing is GrokBillingStatus { + return billing.additionalCreditsUrl === GROK_BUILD_ADDITIONAL_CREDITS_URL; +} + +export function isKimiBillingStatus(billing: ProviderBillingStatus): billing is KimiBillingStatus { + return billing.additionalCreditsUrl === KIMI_CODE_ADDITIONAL_CREDITS_URL; +} diff --git a/tests/unit/kimi-coding-billing-ui.test.ts b/tests/unit/kimi-coding-billing-ui.test.ts new file mode 100644 index 0000000000..985ba05205 --- /dev/null +++ b/tests/unit/kimi-coding-billing-ui.test.ts @@ -0,0 +1,159 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { buildKimiBillingCardRows, KIMI_CODE_ADDITIONAL_CREDITS_URL, sanitizeKimiBillingStatus } = + await import("../../src/shared/utils/kimiBilling.ts"); +const { isKimiBillingStatus, isProviderBillingProvider, sanitizeProviderBillingStatus } = + await import("../../src/shared/utils/providerBilling.ts"); +const { PROVIDER_LABEL } = + await import("../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/constants.ts"); +const { USAGE_SUPPORTED_PROVIDERS } = await import("../../src/shared/constants/providers.ts"); + +const baseBilling = { + currency: "CNY", + extraUsageStatus: "unavailable" as const, + additionalCreditsUrl: KIMI_CODE_ADDITIONAL_CREDITS_URL, +}; + +test("Kimi billing rows show the real Extra Usage status when the wallet is unavailable", () => { + const rows = buildKimiBillingCardRows(baseBilling, "en-US"); + assert.deepEqual(rows, [ + { kind: "status", label: "Extra Usage", value: "Unavailable" }, + { + kind: "link", + label: "Additional Credits", + href: KIMI_CODE_ADDITIONAL_CREDITS_URL, + target: "_blank", + rel: "noreferrer noopener", + }, + ]); +}); + +test("Kimi billing rows show balance, wallet status, monthly spend, cap and buy link", () => { + const rows = buildKimiBillingCardRows( + { + ...baseBilling, + extraCreditsMinorUnits: 1234, + monthlyUsedMinorUnits: 250, + monthlyLimitEnabled: true, + monthlyLimitMinorUnits: 5000, + extraUsageStatus: "enabled", + }, + "en-US" + ); + + assert.deepEqual(rows, [ + { kind: "balance", label: "Extra Usage Credits", value: "CN¥12.34" }, + { kind: "status", label: "Extra Usage", value: "Enabled" }, + { kind: "status", label: "Used this month", value: "CN¥2.50" }, + { kind: "status", label: "Monthly limit", value: "CN¥50.00" }, + { + kind: "link", + label: "Additional Credits", + href: KIMI_CODE_ADDITIONAL_CREDITS_URL, + target: "_blank", + rel: "noreferrer noopener", + }, + ]); +}); + +test("Kimi monthly cap displays Unlimited when disabled or zero", () => { + for (const billing of [ + { ...baseBilling, extraCreditsMinorUnits: 0, monthlyLimitEnabled: false }, + { + ...baseBilling, + extraCreditsMinorUnits: 0, + monthlyLimitEnabled: true, + monthlyLimitMinorUnits: 0, + }, + ]) { + const row = buildKimiBillingCardRows(billing, "en-US").find( + (candidate) => candidate.kind === "status" && candidate.label === "Monthly limit" + ); + assert.deepEqual(row, { kind: "status", label: "Monthly limit", value: "Unlimited" }); + } +}); + +test("Kimi billing labels support localized translation fallbacks", () => { + const translate = (key: string, fallback: string) => + ({ + kimiExtraUsageCredits: "加油包余额", + kimiExtraUsage: "额度加油包", + kimiExtraUsageEnabled: "已开启", + kimiExtraUsageDisabled: "已关闭", + kimiExtraUsageFrozen: "已冻结", + kimiExtraUsageUnavailable: "不可用", + kimiMonthlyUsed: "本月已用", + kimiMonthlyLimit: "每月限额", + kimiMonthlyLimitUnlimited: "无限制", + kimiAdditionalCredits: "充值加油包", + })[key] ?? fallback; + + assert.deepEqual( + buildKimiBillingCardRows( + { + ...baseBilling, + extraCreditsMinorUnits: 0, + monthlyLimitEnabled: false, + extraUsageStatus: "disabled", + }, + "zh-CN", + translate + ), + [ + { kind: "balance", label: "加油包余额", value: "¥0.00" }, + { kind: "status", label: "额度加油包", value: "已关闭" }, + { kind: "status", label: "每月限额", value: "无限制" }, + { + kind: "link", + label: "充值加油包", + href: KIMI_CODE_ADDITIONAL_CREDITS_URL, + target: "_blank", + rel: "noreferrer noopener", + }, + ] + ); +}); + +test("Kimi billing sanitizer strips private fields and rejects forged public contracts", () => { + const billing = sanitizeKimiBillingStatus({ + currency: "cny", + extraCreditsMinorUnits: 0, + monthlyUsedMinorUnits: 250, + monthlyLimitEnabled: true, + monthlyLimitMinorUnits: 5000, + extraUsageStatus: "disabled", + paymentMethodId: "secret", + additionalCreditsUrl: KIMI_CODE_ADDITIONAL_CREDITS_URL, + rawBody: "secret", + }); + + assert.deepEqual(billing, { + currency: "CNY", + extraCreditsMinorUnits: 0, + monthlyUsedMinorUnits: 250, + monthlyLimitEnabled: true, + monthlyLimitMinorUnits: 5000, + extraUsageStatus: "disabled", + additionalCreditsUrl: KIMI_CODE_ADDITIONAL_CREDITS_URL, + }); + assert.equal(buildKimiBillingCardRows(billing!, "zh-CN")[0]?.value, "¥0.00"); + assert.equal(isKimiBillingStatus(billing!), true); + assert.deepEqual(sanitizeProviderBillingStatus(billing), billing); + + for (const forged of [ + { ...baseBilling, currency: "US