feat(FN-1386): add Kimi logo, usage mapping, fetcher, and tests

- Add Kimi logo to ProviderIcon component for visual identification
- Add Kimi provider to usage mapping for cost tracking
- Add Kimi usage fetcher to fetch cost and usage data from Kimi API
- Add ProviderIcon tests for Kimi logo verification
- Add UsageIndicator tests for Kimi provider
- Add comprehensive usage tests for Kimi fetcher
This commit is contained in:
gsxdsm
2026-04-09 14:48:56 -07:00
parent 8c5327a660
commit d2080b190e
6 changed files with 440 additions and 2 deletions

View File

@@ -1369,6 +1369,161 @@ async function fetchZaiUsage(): Promise<ProviderUsage> {
return usage;
}
// ── Kimi (Moonshot AI) fetcher ───────────────────────────────────────────
async function fetchKimiUsage(): Promise<ProviderUsage> {
const usage: ProviderUsage = {
name: "Kimi",
icon: "🌙",
status: "no-auth",
windows: [],
};
// Load Kimi API key from pi's auth storage
const apiKey = readPiAuthKey("kimi-coding");
if (!apiKey) {
usage.error = "No Kimi credentials — add API key to pi";
return usage;
}
try {
const res = await httpsRequest("https://api.moonshot.cn/v1/coding-plan/usage", {
method: "GET",
headers: {
authorization: `Bearer ${apiKey}`,
"content-type": "application/json",
},
});
if (res.status === 401 || res.status === 403) {
usage.status = "error";
usage.error = "Auth expired — check your Kimi API key";
return usage;
}
if (res.status !== 200) {
usage.status = "error";
usage.error = `HTTP ${res.status}: ${res.body.slice(0, 200)}`;
return usage;
}
usage.status = "ok";
const data = JSON.parse(res.body);
// Defensive parsing: try known field names
let windows: any[] = [];
if (Array.isArray(data?.data?.windows)) {
windows = data.data.windows;
} else if (Array.isArray(data?.windows)) {
windows = data.windows;
} else if (Array.isArray(data?.data)) {
// data.data itself may be the windows array
windows = data.data;
}
if (windows.length > 0) {
for (const window of windows) {
const label: string = window.label || window.name || window.type || "Usage";
const total: number = window.total ?? window.limit ?? 0;
const remaining: number = window.remaining ?? window.left ?? 0;
const used: number = window.used ?? Math.max(0, total - remaining);
let percentUsed = 0;
if (total > 0) {
percentUsed = (used / total) * 100;
} else if (remaining >= 0) {
// Fall back: remaining/limit based calculation
const limit = window.limit ?? 0;
if (limit > 0) {
percentUsed = (1 - remaining / limit) * 100;
}
}
let resetText: string | null = null;
let resetMs: number | undefined;
let resetAt: string | undefined;
let windowDurationMs: number | undefined;
const resetTime: number | undefined = window.reset_time ?? window.resets_at;
if (resetTime && resetTime > 0) {
// reset_time may be epoch ms or an ISO string
const resetDate = typeof resetTime === "number" ? new Date(resetTime) : new Date(resetTime);
resetMs = Math.max(0, resetDate.getTime() - Date.now());
resetText = resetMs > 0 ? `resets in ${formatDuration(resetMs)}` : "resetting now";
resetAt = resetDate.toISOString();
// Infer window duration if not provided
if (!window.windowDurationMs && windowDurationMs === undefined) {
windowDurationMs = resetMs > 0 ? resetMs * 2 : undefined; // rough estimate
}
}
usage.windows.push({
label,
percentUsed: Math.min(100, Math.max(0, percentUsed)),
percentLeft: Math.min(100, Math.max(0, 100 - percentUsed)),
resetText,
resetMs,
resetAt,
windowDurationMs,
});
}
} else {
// Single-usage response (data.used / data.total)
const total: number = data?.data?.total ?? data?.total ?? 0;
const used: number = data?.data?.used ?? data?.used ?? 0;
const remaining: number = data?.data?.remaining ?? data?.remaining ?? Math.max(0, total - used);
let percentUsed = 0;
if (total > 0) {
percentUsed = (used / total) * 100;
} else if (remaining >= 0) {
const limit = data?.data?.limit ?? data?.limit ?? 0;
if (limit > 0) {
percentUsed = (1 - remaining / limit) * 100;
}
}
let resetText: string | null = null;
let resetMs: number | undefined;
let resetAt: string | undefined;
let windowDurationMs: number | undefined;
const resetTime: number | undefined = data?.data?.reset_time ?? data?.data?.resets_at ?? data?.reset_time ?? data?.resets_at;
if (resetTime && resetTime > 0) {
const resetDate = typeof resetTime === "number" ? new Date(resetTime) : new Date(resetTime);
resetMs = Math.max(0, resetDate.getTime() - Date.now());
resetText = resetMs > 0 ? `resets in ${formatDuration(resetMs)}` : "resetting now";
resetAt = resetDate.toISOString();
windowDurationMs = resetMs > 0 ? resetMs * 2 : undefined;
}
if (total > 0 || remaining >= 0) {
usage.windows.push({
label: "Coding Plan",
percentUsed: Math.min(100, Math.max(0, percentUsed)),
percentLeft: Math.min(100, Math.max(0, 100 - percentUsed)),
resetText,
resetMs,
resetAt,
windowDurationMs,
});
}
}
// Extract plan information
const planValue: string | undefined = data?.data?.plan ?? data?.data?.level ?? data?.plan ?? data?.level;
if (planValue) {
usage.plan = planValue.charAt(0).toUpperCase() + planValue.slice(1);
}
} catch (e: any) {
usage.status = "error";
usage.error = e.message || "Failed to fetch";
}
return usage;
}
// ── Main export ────────────────────────────────────────────────────────────
/**
@@ -1436,6 +1591,7 @@ export async function fetchAllProviderUsage(_authStorage?: AuthStorageLike): Pro
withTimeout(fetchGeminiUsage(), "Gemini"),
withTimeout(fetchMinimaxUsage(), "Minimax"),
withTimeout(fetchZaiUsage(), "Zai"),
withTimeout(fetchKimiUsage(), "Kimi"),
]);
const providers: ProviderUsage[] = [];