feat(FN-1474): add Kimi endpoint fallback for 404 url.not_found
This commit is contained in:
@@ -3037,6 +3037,238 @@ describe("usage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("endpoint fallback", () => {
|
||||
it("falls back to underscore endpoint when hyphen endpoint returns 404 url.not_found", async () => {
|
||||
const requestedPaths: string[] = [];
|
||||
|
||||
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 mockResponse = {
|
||||
data: {
|
||||
total: 100,
|
||||
used: 25,
|
||||
remaining: 75,
|
||||
reset_time: Date.now() + 3600000,
|
||||
},
|
||||
};
|
||||
|
||||
mockRequest.mockImplementation((options: any, callback: any) => {
|
||||
const pathname = new URL(`https://${options.hostname}${options.path}`).pathname;
|
||||
requestedPaths.push(pathname);
|
||||
|
||||
let statusCode = 200;
|
||||
let responseBody = mockResponse;
|
||||
|
||||
if (pathname === "/v1/coding-plan/usage") {
|
||||
// First endpoint returns 404 with url.not_found
|
||||
statusCode = 404;
|
||||
responseBody = { error: "url.not_found" };
|
||||
}
|
||||
|
||||
const mockRes = {
|
||||
statusCode,
|
||||
headers: {},
|
||||
on: vi.fn((event: string, handler: any) => {
|
||||
if (event === "data") handler(Buffer.from(JSON.stringify(responseBody)));
|
||||
if (event === "end") handler();
|
||||
}),
|
||||
};
|
||||
callback(mockRes);
|
||||
return { on: vi.fn(), write: vi.fn(), end: vi.fn() };
|
||||
});
|
||||
|
||||
const providers = await fetchAllProviderUsage();
|
||||
const kimi = providers.find((p) => p.name === "Kimi")!;
|
||||
|
||||
expect(kimi.status).toBe("ok");
|
||||
expect(kimi.windows).toHaveLength(1);
|
||||
expect(kimi.windows[0].percentUsed).toBe(25);
|
||||
// Should have tried both endpoints
|
||||
expect(requestedPaths).toEqual(["/v1/coding-plan/usage", "/v1/coding_plan/usage"]);
|
||||
});
|
||||
|
||||
it("does not attempt fallback on 401 auth error", async () => {
|
||||
const requestedPaths: string[] = [];
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
mockRequest.mockImplementation((options: any, callback: any) => {
|
||||
const pathname = new URL(`https://${options.hostname}${options.path}`).pathname;
|
||||
requestedPaths.push(pathname);
|
||||
|
||||
const mockRes = {
|
||||
statusCode: 401,
|
||||
headers: {},
|
||||
on: vi.fn((event: string, handler: any) => {
|
||||
if (event === "data") handler(Buffer.from(JSON.stringify({})));
|
||||
if (event === "end") handler();
|
||||
}),
|
||||
};
|
||||
callback(mockRes);
|
||||
return { on: vi.fn(), write: vi.fn(), end: vi.fn() };
|
||||
});
|
||||
|
||||
const providers = await fetchAllProviderUsage();
|
||||
const kimi = providers.find((p) => p.name === "Kimi")!;
|
||||
|
||||
expect(kimi.status).toBe("error");
|
||||
expect(kimi.error).toContain("Auth expired");
|
||||
// Only first endpoint should be attempted (no fallback for auth errors)
|
||||
expect(requestedPaths).toEqual(["/v1/coding-plan/usage"]);
|
||||
});
|
||||
|
||||
it("does not attempt fallback on 403 auth error", async () => {
|
||||
const requestedPaths: string[] = [];
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
mockRequest.mockImplementation((options: any, callback: any) => {
|
||||
const pathname = new URL(`https://${options.hostname}${options.path}`).pathname;
|
||||
requestedPaths.push(pathname);
|
||||
|
||||
const mockRes = {
|
||||
statusCode: 403,
|
||||
headers: {},
|
||||
on: vi.fn((event: string, handler: any) => {
|
||||
if (event === "data") handler(Buffer.from(JSON.stringify({})));
|
||||
if (event === "end") handler();
|
||||
}),
|
||||
};
|
||||
callback(mockRes);
|
||||
return { on: vi.fn(), write: vi.fn(), end: vi.fn() };
|
||||
});
|
||||
|
||||
const providers = await fetchAllProviderUsage();
|
||||
const kimi = providers.find((p) => p.name === "Kimi")!;
|
||||
|
||||
expect(kimi.status).toBe("error");
|
||||
expect(kimi.error).toContain("Auth expired");
|
||||
// Only first endpoint should be attempted (no fallback for auth errors)
|
||||
expect(requestedPaths).toEqual(["/v1/coding-plan/usage"]);
|
||||
});
|
||||
|
||||
it("returns error without fallback when first endpoint returns 404 without url.not_found", async () => {
|
||||
const requestedPaths: string[] = [];
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
mockRequest.mockImplementation((options: any, callback: any) => {
|
||||
const pathname = new URL(`https://${options.hostname}${options.path}`).pathname;
|
||||
requestedPaths.push(pathname);
|
||||
|
||||
const mockRes = {
|
||||
statusCode: 404,
|
||||
headers: {},
|
||||
on: vi.fn((event: string, handler: any) => {
|
||||
// Return a 404 without url.not_found
|
||||
if (event === "data") handler(Buffer.from(JSON.stringify({ error: "something_else" })));
|
||||
if (event === "end") handler();
|
||||
}),
|
||||
};
|
||||
callback(mockRes);
|
||||
return { on: vi.fn(), write: vi.fn(), end: vi.fn() };
|
||||
});
|
||||
|
||||
const providers = await fetchAllProviderUsage();
|
||||
const kimi = providers.find((p) => p.name === "Kimi")!;
|
||||
|
||||
expect(kimi.status).toBe("error");
|
||||
expect(kimi.error).toContain("HTTP 404");
|
||||
// Should NOT attempt fallback because error is not url.not_found
|
||||
expect(requestedPaths).toEqual(["/v1/coding-plan/usage"]);
|
||||
});
|
||||
|
||||
it("succeeds with single endpoint when first endpoint works", async () => {
|
||||
const requestedPaths: string[] = [];
|
||||
|
||||
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 mockResponse = {
|
||||
data: {
|
||||
total: 100,
|
||||
used: 50,
|
||||
remaining: 50,
|
||||
reset_time: Date.now() + 3600000,
|
||||
},
|
||||
};
|
||||
|
||||
mockRequest.mockImplementation((options: any, callback: any) => {
|
||||
const pathname = new URL(`https://${options.hostname}${options.path}`).pathname;
|
||||
requestedPaths.push(pathname);
|
||||
|
||||
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 { on: vi.fn(), write: vi.fn(), end: vi.fn() };
|
||||
});
|
||||
|
||||
const providers = await fetchAllProviderUsage();
|
||||
const kimi = providers.find((p) => p.name === "Kimi")!;
|
||||
|
||||
expect(kimi.status).toBe("ok");
|
||||
expect(kimi.windows).toHaveLength(1);
|
||||
expect(kimi.windows[0].percentUsed).toBe(50);
|
||||
// Should only request first endpoint
|
||||
expect(requestedPaths).toEqual(["/v1/coding-plan/usage"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Claude CLI fallback parsing", () => {
|
||||
describe("_stripClaudeAnsi", () => {
|
||||
it("strips basic ANSI color codes", () => {
|
||||
|
||||
@@ -1429,6 +1429,26 @@ async function fetchZaiUsage(): Promise<ProviderUsage> {
|
||||
|
||||
// ── Kimi (Moonshot AI) fetcher ───────────────────────────────────────────
|
||||
|
||||
// Kimi API endpoints (Moonshot domain)
|
||||
const KIMI_ENDPOINTS = [
|
||||
"https://api.moonshot.cn/v1/coding-plan/usage", // hyphen
|
||||
"https://api.moonshot.cn/v1/coding_plan/usage", // underscore
|
||||
];
|
||||
|
||||
/**
|
||||
* Check if the response body indicates a Moonshot endpoint-not-found error.
|
||||
* Moonshot returns {"error": "url.not_found"} for missing endpoints.
|
||||
*/
|
||||
function isMoonshotNotFound(body: string): boolean {
|
||||
try {
|
||||
const data = JSON.parse(body);
|
||||
// Moonshot returns {"error": "url.not_found"} for missing endpoints
|
||||
return data?.error === "url.not_found";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchKimiUsage(): Promise<ProviderUsage> {
|
||||
const usage: ProviderUsage = {
|
||||
name: "Kimi",
|
||||
@@ -1444,29 +1464,62 @@ async function fetchKimiUsage(): Promise<ProviderUsage> {
|
||||
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",
|
||||
},
|
||||
});
|
||||
let responseBody: string | null = null;
|
||||
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
usage.status = "error";
|
||||
usage.error = "Auth expired — check your Kimi API key";
|
||||
return usage;
|
||||
try {
|
||||
// Try each endpoint in order
|
||||
for (let i = 0; i < KIMI_ENDPOINTS.length; i++) {
|
||||
const endpoint = KIMI_ENDPOINTS[i];
|
||||
const isLastEndpoint = i === KIMI_ENDPOINTS.length - 1;
|
||||
|
||||
const res = await httpsRequest(endpoint, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
authorization: `Bearer ${apiKey}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
// Auth errors short-circuit (no fallback for 401/403)
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
usage.status = "error";
|
||||
usage.error = "Auth expired — check your Kimi API key";
|
||||
return usage;
|
||||
}
|
||||
|
||||
// 404 with url.not_found triggers fallback to next endpoint
|
||||
if (res.status === 404 && isMoonshotNotFound(res.body)) {
|
||||
if (isLastEndpoint) {
|
||||
// Last endpoint also returned not_found — return error
|
||||
usage.status = "error";
|
||||
usage.error = `HTTP 404: ${res.body.slice(0, 200)}`;
|
||||
return usage;
|
||||
}
|
||||
// Try next endpoint
|
||||
continue;
|
||||
}
|
||||
|
||||
// Any other non-200 status returns error
|
||||
if (res.status !== 200) {
|
||||
usage.status = "error";
|
||||
usage.error = `HTTP ${res.status}: ${res.body.slice(0, 200)}`;
|
||||
return usage;
|
||||
}
|
||||
|
||||
// Success — store body and break out of loop
|
||||
responseBody = res.body;
|
||||
break;
|
||||
}
|
||||
|
||||
if (res.status !== 200) {
|
||||
// Should have a response body at this point
|
||||
if (responseBody === null) {
|
||||
usage.status = "error";
|
||||
usage.error = `HTTP ${res.status}: ${res.body.slice(0, 200)}`;
|
||||
usage.error = "Failed to fetch Kimi usage — no valid response";
|
||||
return usage;
|
||||
}
|
||||
|
||||
usage.status = "ok";
|
||||
const data = JSON.parse(res.body);
|
||||
const data = JSON.parse(responseBody);
|
||||
|
||||
// Defensive parsing: try known field names
|
||||
let windows: any[] = [];
|
||||
|
||||
Reference in New Issue
Block a user