feat(FN-1517): harden Kimi 404 handling to retry on any 404
- Add comprehensive tests for Kimi API 404 retry behavior - Expand retry logic to handle any 404 response from Kimi API - Add test cases for various 404 scenarios including context errors and quota exhaustion - Improve error handling resilience for Kimi model calls
This commit is contained in:
@@ -3238,7 +3238,131 @@ describe("usage", () => {
|
|||||||
expect(requestedPaths).toEqual(["/v1/coding_plan/usage"]);
|
expect(requestedPaths).toEqual(["/v1/coding_plan/usage"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns error without fallback when first endpoint returns 404 without url.not_found", async () => {
|
it("falls back to hyphen endpoint when first endpoint returns 404 with non-url.not_found body", async () => {
|
||||||
|
// Regression test: ANY 404 triggers fallback, not just url.not_found
|
||||||
|
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 mockFallbackResponse = {
|
||||||
|
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);
|
||||||
|
|
||||||
|
let statusCode = 200;
|
||||||
|
let responseBody = mockFallbackResponse;
|
||||||
|
|
||||||
|
if (pathname === "/v1/coding_plan/usage") {
|
||||||
|
// First endpoint returns 404 with non-url.not_found body
|
||||||
|
statusCode = 404;
|
||||||
|
responseBody = { error: "something_else" };
|
||||||
|
}
|
||||||
|
|
||||||
|
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(50);
|
||||||
|
// Should attempt both endpoints: underscore first (404), then hyphen fallback
|
||||||
|
expect(requestedPaths).toEqual(["/v1/coding_plan/usage", "/v1/coding-plan/usage"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back when first endpoint returns 404 with non-JSON body", async () => {
|
||||||
|
// Regression test: non-JSON 404 body should still trigger fallback
|
||||||
|
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 mockFallbackResponse = {
|
||||||
|
data: {
|
||||||
|
total: 100,
|
||||||
|
used: 75,
|
||||||
|
remaining: 25,
|
||||||
|
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 = mockFallbackResponse;
|
||||||
|
|
||||||
|
if (pathname === "/v1/coding_plan/usage") {
|
||||||
|
// First endpoint returns 404 with plain text (not JSON)
|
||||||
|
statusCode = 404;
|
||||||
|
responseBody = "Not Found";
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockRes = {
|
||||||
|
statusCode,
|
||||||
|
headers: { "content-type": "text/plain" },
|
||||||
|
on: vi.fn((event: string, handler: any) => {
|
||||||
|
if (event === "data") {
|
||||||
|
const body = typeof responseBody === "string" ? responseBody : JSON.stringify(responseBody);
|
||||||
|
handler(Buffer.from(body));
|
||||||
|
}
|
||||||
|
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(75);
|
||||||
|
// Should attempt both endpoints
|
||||||
|
expect(requestedPaths).toEqual(["/v1/coding_plan/usage", "/v1/coding-plan/usage"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns error when both endpoints return 404", async () => {
|
||||||
|
// Regression test: when both endpoints return 404, should return error
|
||||||
const requestedPaths: string[] = [];
|
const requestedPaths: string[] = [];
|
||||||
|
|
||||||
mockReadFileSync.mockImplementation((filePath: string) => {
|
mockReadFileSync.mockImplementation((filePath: string) => {
|
||||||
@@ -3261,8 +3385,7 @@ describe("usage", () => {
|
|||||||
statusCode: 404,
|
statusCode: 404,
|
||||||
headers: {},
|
headers: {},
|
||||||
on: vi.fn((event: string, handler: any) => {
|
on: vi.fn((event: string, handler: any) => {
|
||||||
// Return a 404 without url.not_found
|
if (event === "data") handler(Buffer.from("Not Found"));
|
||||||
if (event === "data") handler(Buffer.from(JSON.stringify({ error: "something_else" })));
|
|
||||||
if (event === "end") handler();
|
if (event === "end") handler();
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -3275,8 +3398,8 @@ describe("usage", () => {
|
|||||||
|
|
||||||
expect(kimi.status).toBe("error");
|
expect(kimi.status).toBe("error");
|
||||||
expect(kimi.error).toContain("HTTP 404");
|
expect(kimi.error).toContain("HTTP 404");
|
||||||
// Should NOT attempt fallback because error is not url.not_found
|
// Should attempt both endpoints
|
||||||
expect(requestedPaths).toEqual(["/v1/coding_plan/usage"]);
|
expect(requestedPaths).toEqual(["/v1/coding_plan/usage", "/v1/coding-plan/usage"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("succeeds with single endpoint when first endpoint works", async () => {
|
it("succeeds with single endpoint when first endpoint works", async () => {
|
||||||
|
|||||||
@@ -1432,25 +1432,12 @@ async function fetchZaiUsage(): Promise<ProviderUsage> {
|
|||||||
// Kimi API endpoints (Moonshot domain)
|
// Kimi API endpoints (Moonshot domain)
|
||||||
// NOTE: Underscore endpoint is first — this is the Codexbar-validated working endpoint.
|
// NOTE: Underscore endpoint is first — this is the Codexbar-validated working endpoint.
|
||||||
// Hyphen variant is kept as fallback for older accounts/API versions that may still use it.
|
// Hyphen variant is kept as fallback for older accounts/API versions that may still use it.
|
||||||
|
// Both endpoints are tried in order; any 404 on one triggers fallback to the next.
|
||||||
const KIMI_ENDPOINTS = [
|
const KIMI_ENDPOINTS = [
|
||||||
"https://api.moonshot.cn/v1/coding_plan/usage", // underscore (primary, Codexbar-validated)
|
"https://api.moonshot.cn/v1/coding_plan/usage", // underscore (primary, Codexbar-validated)
|
||||||
"https://api.moonshot.cn/v1/coding-plan/usage", // hyphen (legacy fallback)
|
"https://api.moonshot.cn/v1/coding-plan/usage", // hyphen (legacy fallback)
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
|
||||||
* 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> {
|
async function fetchKimiUsage(): Promise<ProviderUsage> {
|
||||||
const usage: ProviderUsage = {
|
const usage: ProviderUsage = {
|
||||||
name: "Kimi",
|
name: "Kimi",
|
||||||
@@ -1482,17 +1469,19 @@ async function fetchKimiUsage(): Promise<ProviderUsage> {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auth errors short-circuit (no fallback for 401/403)
|
// Auth errors short-circuit immediately (no fallback for 401/403)
|
||||||
if (res.status === 401 || res.status === 403) {
|
if (res.status === 401 || res.status === 403) {
|
||||||
usage.status = "error";
|
usage.status = "error";
|
||||||
usage.error = "Auth expired — check your Kimi API key";
|
usage.error = "Auth expired — check your Kimi API key";
|
||||||
return usage;
|
return usage;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 404 with url.not_found triggers fallback to next endpoint
|
// Any 404 triggers fallback to next endpoint (regardless of body content).
|
||||||
if (res.status === 404 && isMoonshotNotFound(res.body)) {
|
// Some accounts may return 404 with non-standard body shapes (non-JSON, alternate JSON).
|
||||||
|
// The underscore endpoint is tried first; if it returns 404, the hyphen endpoint is tried.
|
||||||
|
if (res.status === 404) {
|
||||||
if (isLastEndpoint) {
|
if (isLastEndpoint) {
|
||||||
// Last endpoint also returned not_found — return error
|
// Last endpoint also returned 404 — return error
|
||||||
usage.status = "error";
|
usage.status = "error";
|
||||||
usage.error = `HTTP 404: ${res.body.slice(0, 200)}`;
|
usage.error = `HTTP 404: ${res.body.slice(0, 200)}`;
|
||||||
return usage;
|
return usage;
|
||||||
|
|||||||
Reference in New Issue
Block a user