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

@@ -131,6 +131,32 @@ function ZaiIcon({ size, color, label = "Z.ai" }: { size: number; color: string;
);
}
// Kimi / Moonshot AI logo — crescent moon with star
function KimiIcon({ size, color, label = "Kimi" }: { size: number; color: string; label?: string }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
data-testid="kimi-icon"
aria-label={label}
>
{/* Crescent moon */}
<path
d="M12 3C7.03 3 3 7.03 3 12s4.03 9 9 9c1.66 0 3.22-.45 4.57-1.23C14.76 17.82 13 15 13 12c0-3.87 3.13-7 7-7 1.66 0 3.22.45 4.57 1.23-1.27 1.97-3.35 3.22-5.57 3.22C14.76 9.45 13 12 13 12c0 3.87-3.13 7-7 7-1.66 0-3.22-.45-4.57-1.23C3.24 19.97 5.32 21 7.54 21 11.24 21 13 17.97 13 14c0-1.1.9-2 2-2s2 .9 2 2c0 2.97-1.76 6-5.46 6-1.66 0-3.22-.45-4.57-1.23C8.68 20.72 10.21 21 11.76 21 16.97 21 21 16.97 21 12c0-4.97-4.03-9-9-9z"
fill={color}
/>
{/* Star sparkle */}
<path
d="M19.5 4.5l-1 1.5 2 .5-.5 2 1.5-1-1-1.5.5-2z"
fill={color}
/>
</svg>
);
}
const providerConfig: Record<
string,
{ component: typeof AnthropicIcon; color: string; label?: string }
@@ -143,6 +169,7 @@ const providerConfig: Record<
ollama: { component: OllamaIcon, color: "#fff" }, // white
minimax: { component: MiniMaxIcon, color: "#E73562" }, // pink/red
zai: { component: ZaiIcon, color: "#1A6DFF" }, // blue
kimi: { component: KimiIcon, color: "#6C5CE7" }, // purple
};
export function ProviderIcon({ provider, size = "sm" }: ProviderIconProps) {

View File

@@ -575,6 +575,22 @@ describe("UsageIndicator", () => {
expect(document.querySelector("svg[aria-label='Google Gemini']")).toBeInTheDocument();
});
it("maps Kimi provider to kimi icon", () => {
mockUseUsageData.mockReturnValue({
providers: [
{ name: "Kimi", icon: "🌙", status: "ok", windows: [] },
],
loading: false,
error: null,
lastUpdated: new Date(),
refresh: mockRefresh,
});
render(<UsageIndicator isOpen={true} onClose={mockOnClose} projectId={TEST_PROJECT_ID} />);
expect(document.querySelector('[data-provider="kimi"]')).toBeInTheDocument();
});
// Pace indicator tests
it("renders pace marker for weekly windows with timing data", () => {
mockUseUsageData.mockReturnValue({

View File

@@ -235,6 +235,9 @@ function getProviderIconKey(providerName: string): string {
if (normalized.includes('zai') || normalized.includes('zhipu')) {
return 'zai';
}
if (normalized.includes('kimi')) {
return 'kimi';
}
// Return the original name as fallback (ProviderIcon will show a default icon)
return providerName;

View File

@@ -236,4 +236,31 @@ describe("ProviderIcon", () => {
const wrapper = screen.getByTestId("zai-icon").parentElement;
expect(wrapper).toHaveAttribute("data-provider", "zai");
});
it("renders Kimi brand icon for kimi provider", () => {
render(<ProviderIcon provider="kimi" />);
expect(screen.getByTestId("kimi-icon")).toBeInTheDocument();
expect(screen.getByLabelText("Kimi")).toBeInTheDocument();
});
it("applies provider-specific color for kimi", () => {
render(<ProviderIcon provider="kimi" />);
const icon = screen.getByTestId("kimi-icon").parentElement;
expect(icon).toHaveStyle({ color: "#6C5CE7" });
});
it("passes correct color to SVG fill for kimi", () => {
render(<ProviderIcon provider="kimi" />);
const svg = screen.getByTestId("kimi-icon");
const paths = svg.querySelectorAll("path");
expect(paths.length).toBeGreaterThan(0);
expect(paths[0]).toHaveAttribute("fill", "#6C5CE7");
});
it("normalizes Kimi (capitalized) to kimi", () => {
render(<ProviderIcon provider="Kimi" />);
expect(screen.getByTestId("kimi-icon")).toBeInTheDocument();
const wrapper = screen.getByTestId("kimi-icon").parentElement;
expect(wrapper).toHaveAttribute("data-provider", "kimi");
});
});

View File

@@ -61,12 +61,13 @@ describe("usage", () => {
const providers = await fetchAllProviderUsage();
expect(providers).toHaveLength(5);
expect(providers).toHaveLength(6);
expect(providers.map((p) => p.name)).toContain("Claude");
expect(providers.map((p) => p.name)).toContain("Codex");
expect(providers.map((p) => p.name)).toContain("Gemini");
expect(providers.map((p) => p.name)).toContain("Minimax");
expect(providers.map((p) => p.name)).toContain("Zai");
expect(providers.map((p) => p.name)).toContain("Kimi");
// All should be no-auth status
for (const p of providers) {
@@ -101,7 +102,7 @@ describe("usage", () => {
// Should be different array reference
expect(second).not.toBe(first);
expect(second).toHaveLength(5);
expect(second).toHaveLength(6);
});
});
@@ -2827,6 +2828,214 @@ describe("usage", () => {
});
});
describe("Kimi provider", () => {
it("detects no auth when pi auth.json doesn't exist", async () => {
mockReadFileSync.mockImplementation(() => {
throw new Error("File not found");
});
mockExecFileSync.mockImplementation(() => {
throw new Error("Keychain item not found");
});
const providers = await fetchAllProviderUsage();
const kimi = providers.find((p) => p.name === "Kimi");
expect(kimi).toBeDefined();
expect(kimi!.status).toBe("no-auth");
expect(kimi!.error).toContain("No Kimi credentials");
});
it("detects no auth when kimi-coding entry has no key", async () => {
mockReadFileSync.mockImplementation((filePath: string) => {
if (filePath.includes(".pi/agent/auth.json")) {
return JSON.stringify({
"kimi-coding": { type: "api_key" /* missing key */ },
});
}
throw new Error("File not found");
});
mockExecFileSync.mockImplementation(() => {
throw new Error("Keychain item not found");
});
const providers = await fetchAllProviderUsage();
const kimi = providers.find((p) => p.name === "Kimi");
expect(kimi!.status).toBe("no-auth");
expect(kimi!.error).toContain("No Kimi credentials");
});
it("detects no auth when kimi-coding entry is missing entirely", async () => {
mockReadFileSync.mockImplementation((filePath: string) => {
if (filePath.includes(".pi/agent/auth.json")) {
return JSON.stringify({ /* no kimi-coding key */ });
}
throw new Error("File not found");
});
mockExecFileSync.mockImplementation(() => {
throw new Error("Keychain item not found");
});
const providers = await fetchAllProviderUsage();
const kimi = providers.find((p) => p.name === "Kimi");
expect(kimi!.status).toBe("no-auth");
expect(kimi!.error).toContain("No Kimi credentials");
});
it("parses usage data from API response with windows array", async () => {
const now = Date.now();
const mockResponse = {
data: {
windows: [
{
label: "Coding",
used: 150,
total: 500,
reset_time: now + 3 * 60 * 60 * 1000,
},
{
label: "MCP",
used: 80,
total: 200,
remaining: 120,
reset_time: now + 24 * 60 * 60 * 1000,
},
],
plan: "pro",
},
};
mockReadFileSync.mockImplementation((filePath: string) => {
if (filePath.includes(".pi/agent/auth.json")) {
return JSON.stringify({
"kimi-coding": { type: "api_key", key: "test-api-key" },
});
}
throw new Error("File not found");
});
mockExecFileSync.mockImplementation(() => {
throw new Error("Keychain item not found");
});
const mockReq = {
on: vi.fn(),
write: vi.fn(),
end: vi.fn(),
};
mockRequest.mockImplementation((_options: any, callback: any) => {
const mockRes = {
statusCode: 200,
headers: {},
on: vi.fn((event: string, handler: any) => {
if (event === "data") {
handler(Buffer.from(JSON.stringify(mockResponse)));
}
if (event === "end") {
handler();
}
}),
};
callback(mockRes);
return mockReq;
});
const providers = await fetchAllProviderUsage();
const kimi = providers.find((p) => p.name === "Kimi")!;
expect(kimi.status).toBe("ok");
expect(kimi.plan).toBe("Pro");
expect(kimi.windows).toHaveLength(2);
const codingWindow = kimi.windows.find((w) => w.label === "Coding")!;
expect(codingWindow).toBeDefined();
// used=150, total=500 → 150/500*100 = 30%
expect(codingWindow.percentUsed).toBe(30);
expect(codingWindow.percentLeft).toBe(70);
expect(codingWindow.resetText).toContain("resets in");
});
it("returns error on 401 response", async () => {
mockReadFileSync.mockImplementation((filePath: string) => {
if (filePath.includes(".pi/agent/auth.json")) {
return JSON.stringify({
"kimi-coding": { type: "api_key", key: "bad-key" },
});
}
throw new Error("File not found");
});
mockExecFileSync.mockImplementation(() => {
throw new Error("Keychain item not found");
});
const mockReq = { on: vi.fn(), write: vi.fn(), end: vi.fn() };
mockRequest.mockImplementation((_options: any, callback: any) => {
const mockRes = {
statusCode: 401,
headers: {},
on: vi.fn((event: string, handler: any) => {
if (event === "data") handler(Buffer.from('{"error": "unauthorized"}'));
if (event === "end") handler();
}),
};
callback(mockRes);
return mockReq;
});
const providers = await fetchAllProviderUsage();
const kimi = providers.find((p) => p.name === "Kimi")!;
expect(kimi.status).toBe("error");
expect(kimi.error).toContain("Auth expired");
});
it("extracts plan information from response", async () => {
const mockResponse = {
data: {
used: 100,
total: 500,
reset_time: Date.now() + 60 * 60 * 1000,
level: "enterprise",
},
};
mockReadFileSync.mockImplementation((filePath: string) => {
if (filePath.includes(".pi/agent/auth.json")) {
return JSON.stringify({
"kimi-coding": { type: "api_key", key: "test-api-key" },
});
}
throw new Error("File not found");
});
mockExecFileSync.mockImplementation(() => {
throw new Error("Keychain item not found");
});
const mockReq = { on: vi.fn(), write: vi.fn(), end: vi.fn() };
mockRequest.mockImplementation((_options: any, callback: any) => {
const mockRes = {
statusCode: 200,
headers: {},
on: vi.fn((event: string, handler: any) => {
if (event === "data") handler(Buffer.from(JSON.stringify(mockResponse)));
if (event === "end") handler();
}),
};
callback(mockRes);
return mockReq;
});
const providers = await fetchAllProviderUsage();
const kimi = providers.find((p) => p.name === "Kimi")!;
expect(kimi.status).toBe("ok");
expect(kimi.plan).toBe("Enterprise");
expect(kimi.windows).toHaveLength(1);
expect(kimi.windows[0].label).toBe("Coding Plan");
});
});
describe("Claude CLI fallback parsing", () => {
describe("_stripClaudeAnsi", () => {
it("strips basic ANSI color codes", () => {

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[] = [];