feat: restore Kimi usage dashboard (api.kimi.com/coding/v1/usages)
Recovered kimi-usage.ts from bot session logs — original was deleted during March 27 fallback cleanup. Shows 5h/7d usage bars in dashboard alongside Claude and Codex, using sk-kimi-* coding plan API key.
This commit is contained in:
214
src/kimi-usage.ts
Normal file
214
src/kimi-usage.ts
Normal file
@@ -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<KimiUsageData | null> {
|
||||||
|
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<string, string> = {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -187,10 +187,7 @@ let globalFailoverActive = false;
|
|||||||
let globalFailoverReason: string | null = null;
|
let globalFailoverReason: string | null = null;
|
||||||
let globalFailoverActivatedAt: string | null = null;
|
let globalFailoverActivatedAt: string | null = null;
|
||||||
|
|
||||||
export function activateCodexFailover(
|
export function activateCodexFailover(_chatJid: string, reason: string): void {
|
||||||
_chatJid: string,
|
|
||||||
reason: string,
|
|
||||||
): void {
|
|
||||||
globalFailoverActive = true;
|
globalFailoverActive = true;
|
||||||
globalFailoverReason = reason;
|
globalFailoverReason = reason;
|
||||||
globalFailoverActivatedAt = new Date().toISOString();
|
globalFailoverActivatedAt = new Date().toISOString();
|
||||||
@@ -238,9 +235,7 @@ export interface ActiveFailoverLease {
|
|||||||
export function getActiveCodexFailoverLeases(): ActiveFailoverLease[] {
|
export function getActiveCodexFailoverLeases(): ActiveFailoverLease[] {
|
||||||
// Global failover: report as a single pseudo-lease
|
// Global failover: report as a single pseudo-lease
|
||||||
if (globalFailoverActive) {
|
if (globalFailoverActive) {
|
||||||
return [
|
return [{ chatJid: '*', activatedAt: globalFailoverActivatedAt }];
|
||||||
{ chatJid: '*', activatedAt: globalFailoverActivatedAt },
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
USAGE_DASHBOARD_ENABLED,
|
USAGE_DASHBOARD_ENABLED,
|
||||||
getMoaConfig,
|
getMoaConfig,
|
||||||
} from './config.js';
|
} from './config.js';
|
||||||
|
import { fetchKimiUsage, buildKimiUsageRows } from './kimi-usage.js';
|
||||||
import { getGlobalFailoverInfo } from './service-routing.js';
|
import { getGlobalFailoverInfo } from './service-routing.js';
|
||||||
import {
|
import {
|
||||||
fetchAllClaudeUsage,
|
fetchAllClaudeUsage,
|
||||||
@@ -87,6 +88,7 @@ const RENDERER_USAGE_REFRESH_MS = 30_000;
|
|||||||
let statusMessageId: string | null = null;
|
let statusMessageId: string | null = null;
|
||||||
let cachedUsageContent = '';
|
let cachedUsageContent = '';
|
||||||
let cachedClaudeAccounts: ClaudeAccountUsage[] = [];
|
let cachedClaudeAccounts: ClaudeAccountUsage[] = [];
|
||||||
|
let cachedKimiUsage: import('./kimi-usage.js').KimiUsageData | null = null;
|
||||||
let usageUpdateInProgress = false;
|
let usageUpdateInProgress = false;
|
||||||
let channelMetaCache = new Map<string, ChannelMeta>();
|
let channelMetaCache = new Map<string, ChannelMeta>();
|
||||||
let channelMetaLastRefresh = 0;
|
let channelMetaLastRefresh = 0;
|
||||||
@@ -471,6 +473,13 @@ async function buildUsageContent(): Promise<string> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kimi usage
|
||||||
|
try {
|
||||||
|
cachedKimiUsage = await fetchKimiUsage();
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn({ err }, 'Failed to fetch Kimi usage for dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
const lines: string[] = ['📊 *사용량*'];
|
const lines: string[] = ['📊 *사용량*'];
|
||||||
const bar = (pct: number) => {
|
const bar = (pct: number) => {
|
||||||
const filled = Math.max(0, Math.min(5, Math.round(pct / 20)));
|
const filled = Math.max(0, Math.min(5, Math.round(pct / 20)));
|
||||||
@@ -502,6 +511,10 @@ async function buildUsageContent(): Promise<string> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Group 3: Kimi coding plan
|
||||||
|
const kimiRows = buildKimiUsageRows(cachedKimiUsage);
|
||||||
|
claudeBotRows.push(...kimiRows);
|
||||||
|
|
||||||
lines.push(...renderUsageTable(claudeBotRows, codexBotRows));
|
lines.push(...renderUsageTable(claudeBotRows, codexBotRows));
|
||||||
|
|
||||||
lines.push('');
|
lines.push('');
|
||||||
@@ -571,9 +584,10 @@ function buildModelConfigSection(): string {
|
|||||||
for (const role of roleConfigs) {
|
for (const role of roleConfigs) {
|
||||||
if (!role.agentType && role.label === 'Arbiter') continue;
|
if (!role.agentType && role.label === 'Arbiter') continue;
|
||||||
const type = role.agentType || '—';
|
const type = role.agentType || '—';
|
||||||
const defaultModel = type === 'codex'
|
const defaultModel =
|
||||||
? (process.env.CODEX_MODEL || 'codex')
|
type === 'codex'
|
||||||
: (process.env.CLAUDE_MODEL || 'claude');
|
? process.env.CODEX_MODEL || 'codex'
|
||||||
|
: process.env.CLAUDE_MODEL || 'claude';
|
||||||
const model = role.model || defaultModel;
|
const model = role.model || defaultModel;
|
||||||
lines.push(` **${role.label}** — ${type} \`${model}\``);
|
lines.push(` **${role.label}** — ${type} \`${model}\``);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user