Files
EJClaw/src/kimi-usage.ts
Eyejoker da27d30079 feat: MoA supports Anthropic Messages format (MOA_{NAME}_API_FORMAT=anthropic)
Kimi coding plan and GLM both use Anthropic-compatible API, not OpenAI.
queryModel now dispatches to /v1/messages or /chat/completions based on
apiFormat config per reference model.
2026-03-31 01:49:34 +09:00

209 lines
6.2 KiB
TypeScript

/**
* 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;
}