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:
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
Reference in New Issue
Block a user