/** * Kimi Usage API * * Fetches subscription quota usage from the Kimi Code API. * Endpoint: GET https://api.kimi.com/coding/v1/usages * Auth: Bearer token (same as FALLBACK_AUTH_TOKEN or MOA_KIMI_API_KEY) * * Response maps to the same 5h / weekly window structure as Claude/Codex. */ import { getEnv } from './env.js'; import { logger } from './logger.js'; import { formatResetRemaining, type UsageRow } from './dashboard-usage-rows.js'; // ── Types ──────────────────────────────────────────────────────── export interface KimiUsageResponse { user?: { userId?: string; region?: string; membership?: { level?: string }; }; /** Weekly (rolling) quota */ usage?: { limit?: string; used?: string; remaining?: string; resetTime?: string; }; /** Per-window limits (e.g. 5-hour sliding window) */ limits?: Array<{ window?: { duration?: number; timeUnit?: string }; detail?: { limit?: string; used?: string; remaining?: string; resetTime?: string; }; }>; parallel?: { limit?: string }; } export interface KimiUsageData { /** 5-hour sliding window */ fiveHour?: { pct: number; resetTime: string }; /** Weekly rolling quota */ weekly?: { pct: number; resetTime: string }; /** Membership level (e.g. "LEVEL_INTERMEDIATE") */ membershipLevel?: string; } // ── Config ─────────────────────────────────────────────────────── const FETCH_TIMEOUT_MS = 10_000; const MIN_FETCH_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes let lastFetchAt = 0; let cachedData: KimiUsageData | null = null; function getKimiConfig(): { baseUrl: string; authToken: string } | null { // Try MoA config first, then fallback config const authToken = getEnv('MOA_KIMI_API_KEY') || getEnv('FALLBACK_AUTH_TOKEN') || ''; const baseUrl = getEnv('MOA_KIMI_BASE_URL') || getEnv('FALLBACK_BASE_URL') || ''; if (!baseUrl || !authToken) return null; return { baseUrl, authToken }; } // ── Parsing ────────────────────────────────────────────────────── function parseWindowPct(detail?: { limit?: string; used?: string; remaining?: string; }): number { if (!detail) return -1; const limit = parseInt(detail.limit || '0', 10); if (limit <= 0) return -1; if (detail.used != null) { const used = parseInt(detail.used, 10); return Math.round((used / limit) * 100); } if (detail.remaining != null) { const remaining = parseInt(detail.remaining, 10); return Math.round(((limit - remaining) / limit) * 100); } return -1; } function parseResponse(resp: KimiUsageResponse): KimiUsageData { const data: KimiUsageData = {}; // Weekly quota (top-level "usage" field) if (resp.usage) { const pct = parseWindowPct(resp.usage); data.weekly = { pct, resetTime: resp.usage.resetTime || '' }; } // 5-hour sliding window (first entry in "limits" array) if (resp.limits && resp.limits.length > 0) { const first = resp.limits[0]; if (first.detail) { const pct = parseWindowPct(first.detail); data.fiveHour = { pct, resetTime: first.detail.resetTime || '' }; } } // Membership data.membershipLevel = resp.user?.membership?.level; return data; } // ── Fetch ──────────────────────────────────────────────────────── export async function fetchKimiUsage(): Promise { const config = getKimiConfig(); if (!config) return null; const now = Date.now(); if (now - lastFetchAt < MIN_FETCH_INTERVAL_MS && cachedData) { return cachedData; } const url = `${config.baseUrl.replace(/\/+$/, '')}/v1/usages`; try { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); const response = await fetch(url, { method: 'GET', headers: { Authorization: `Bearer ${config.authToken}` }, signal: controller.signal, }); clearTimeout(timer); if (!response.ok) { logger.warn( { status: response.status, url }, 'Kimi usage API returned non-OK status', ); return cachedData; // Return stale cache on failure } const json = (await response.json()) as KimiUsageResponse; cachedData = parseResponse(json); lastFetchAt = Date.now(); logger.debug( { weeklyPct: cachedData.weekly?.pct, fiveHourPct: cachedData.fiveHour?.pct, membership: cachedData.membershipLevel, }, 'Kimi usage fetched', ); return cachedData; } catch (err) { logger.warn({ err, url }, 'Failed to fetch Kimi usage'); return cachedData; // Return stale cache on failure } } // ── UsageRow builder ───────────────────────────────────────────── function formatMembershipLabel(level?: string): string { if (!level) return ''; // "LEVEL_INTERMEDIATE" → "mid", "LEVEL_PREMIUM" → "premium", etc. const cleaned = level.replace(/^LEVEL_/i, '').toLowerCase(); const labelMap: Record = { basic: 'basic', intermediate: 'mid', premium: 'premium', enterprise: 'ent', }; return labelMap[cleaned] || cleaned; } export function buildKimiUsageRows(data: KimiUsageData | null): UsageRow[] { if (!data) return []; const memberLabel = formatMembershipLabel(data.membershipLevel); const name = memberLabel ? `Kimi ${memberLabel}` : 'Kimi'; return [ { name, h5pct: data.fiveHour?.pct ?? -1, h5reset: data.fiveHour?.resetTime ? formatResetRemaining(data.fiveHour.resetTime) : '', d7pct: data.weekly?.pct ?? -1, d7reset: data.weekly?.resetTime ? formatResetRemaining(data.weekly.resetTime) : '', }, ]; } /** Force clear cached data (for testing). */ export function resetKimiUsageCache(): void { cachedData = null; lastFetchAt = 0; }