diff --git a/src/kimi-usage.ts b/src/kimi-usage.ts new file mode 100644 index 0000000..9c6e64a --- /dev/null +++ b/src/kimi-usage.ts @@ -0,0 +1,214 @@ +/** + * 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; +} diff --git a/src/service-routing.ts b/src/service-routing.ts index d18e7c9..2bac9fe 100644 --- a/src/service-routing.ts +++ b/src/service-routing.ts @@ -187,10 +187,7 @@ let globalFailoverActive = false; let globalFailoverReason: string | null = null; let globalFailoverActivatedAt: string | null = null; -export function activateCodexFailover( - _chatJid: string, - reason: string, -): void { +export function activateCodexFailover(_chatJid: string, reason: string): void { globalFailoverActive = true; globalFailoverReason = reason; globalFailoverActivatedAt = new Date().toISOString(); @@ -238,9 +235,7 @@ export interface ActiveFailoverLease { export function getActiveCodexFailoverLeases(): ActiveFailoverLease[] { // Global failover: report as a single pseudo-lease if (globalFailoverActive) { - return [ - { chatJid: '*', activatedAt: globalFailoverActivatedAt }, - ]; + return [{ chatJid: '*', activatedAt: globalFailoverActivatedAt }]; } return []; } diff --git a/src/unified-dashboard.ts b/src/unified-dashboard.ts index ad167df..c60edd6 100644 --- a/src/unified-dashboard.ts +++ b/src/unified-dashboard.ts @@ -13,6 +13,7 @@ import { USAGE_DASHBOARD_ENABLED, getMoaConfig, } from './config.js'; +import { fetchKimiUsage, buildKimiUsageRows } from './kimi-usage.js'; import { getGlobalFailoverInfo } from './service-routing.js'; import { fetchAllClaudeUsage, @@ -87,6 +88,7 @@ const RENDERER_USAGE_REFRESH_MS = 30_000; let statusMessageId: string | null = null; let cachedUsageContent = ''; let cachedClaudeAccounts: ClaudeAccountUsage[] = []; +let cachedKimiUsage: import('./kimi-usage.js').KimiUsageData | null = null; let usageUpdateInProgress = false; let channelMetaCache = new Map(); let channelMetaLastRefresh = 0; @@ -471,6 +473,13 @@ async function buildUsageContent(): Promise { } } + // Kimi usage + try { + cachedKimiUsage = await fetchKimiUsage(); + } catch (err) { + logger.warn({ err }, 'Failed to fetch Kimi usage for dashboard'); + } + const lines: string[] = ['📊 *사용량*']; const bar = (pct: number) => { const filled = Math.max(0, Math.min(5, Math.round(pct / 20))); @@ -502,6 +511,10 @@ async function buildUsageContent(): Promise { ); } + // Group 3: Kimi coding plan + const kimiRows = buildKimiUsageRows(cachedKimiUsage); + claudeBotRows.push(...kimiRows); + lines.push(...renderUsageTable(claudeBotRows, codexBotRows)); lines.push(''); @@ -571,9 +584,10 @@ function buildModelConfigSection(): string { for (const role of roleConfigs) { if (!role.agentType && role.label === 'Arbiter') continue; const type = role.agentType || '—'; - const defaultModel = type === 'codex' - ? (process.env.CODEX_MODEL || 'codex') - : (process.env.CLAUDE_MODEL || 'claude'); + const defaultModel = + type === 'codex' + ? process.env.CODEX_MODEL || 'codex' + : process.env.CLAUDE_MODEL || 'claude'; const model = role.model || defaultModel; lines.push(` **${role.label}** — ${type} \`${model}\``); }