diff --git a/src/infra/provider-usage.fetch.ts b/src/infra/provider-usage.fetch.ts index 070396554..c9f904f2f 100644 --- a/src/infra/provider-usage.fetch.ts +++ b/src/infra/provider-usage.fetch.ts @@ -4,4 +4,4 @@ export { fetchCodexUsage } from "./provider-usage.fetch.codex.js"; export { fetchCopilotUsage } from "./provider-usage.fetch.copilot.js"; export { fetchGeminiUsage } from "./provider-usage.fetch.gemini.js"; export { fetchMinimaxUsage } from "./provider-usage.fetch.minimax.js"; -export { fetchZaiUsage } from "./provider-usage.fetch.zai.js"; +export { fetchGlmUsage, fetchZaiUsage } from "./provider-usage.fetch.zai.js"; diff --git a/src/infra/provider-usage.fetch.zai.ts b/src/infra/provider-usage.fetch.zai.ts index 03237f279..30b417e4d 100644 --- a/src/infra/provider-usage.fetch.zai.ts +++ b/src/infra/provider-usage.fetch.zai.ts @@ -1,6 +1,10 @@ import { fetchJson } from "./provider-usage.fetch.shared.js"; import { clampPercent, PROVIDER_LABELS } from "./provider-usage.shared.js"; -import type { ProviderUsageSnapshot, UsageWindow } from "./provider-usage.types.js"; +import type { + ProviderUsageSnapshot, + UsageProviderId, + UsageWindow, +} from "./provider-usage.types.js"; type ZaiUsageResponse = { success?: boolean; @@ -19,13 +23,24 @@ type ZaiUsageResponse = { }; }; -export async function fetchZaiUsage( +type GlmUsageProviderId = "zai" | "zai-coding" | "zhipu" | "zhipu-coding"; + +const GLM_USAGE_URLS: Record = { + zai: "https://api.z.ai/api/monitor/usage/quota/limit", + "zai-coding": "https://api.z.ai/api/monitor/usage/quota/limit", + zhipu: "https://open.bigmodel.cn/api/monitor/usage/quota/limit", + "zhipu-coding": "https://open.bigmodel.cn/api/monitor/usage/quota/limit", +}; + +export async function fetchGlmUsage( + provider: GlmUsageProviderId, apiKey: string, timeoutMs: number, fetchFn: typeof fetch, ): Promise { + const url = GLM_USAGE_URLS[provider]; const res = await fetchJson( - "https://api.z.ai/api/monitor/usage/quota/limit", + url, { method: "GET", headers: { @@ -39,8 +54,8 @@ export async function fetchZaiUsage( if (!res.ok) { return { - provider: "zai", - displayName: PROVIDER_LABELS.zai, + provider: provider as UsageProviderId, + displayName: PROVIDER_LABELS[provider], windows: [], error: `HTTP ${res.status}`, }; @@ -49,8 +64,8 @@ export async function fetchZaiUsage( const data = (await res.json()) as ZaiUsageResponse; if (!data.success || data.code !== 200) { return { - provider: "zai", - displayName: PROVIDER_LABELS.zai, + provider: provider as UsageProviderId, + displayName: PROVIDER_LABELS[provider], windows: [], error: data.msg || "API error", }; @@ -84,9 +99,18 @@ export async function fetchZaiUsage( const planName = data.data?.planName || data.data?.plan || undefined; return { - provider: "zai", - displayName: PROVIDER_LABELS.zai, + provider: provider as UsageProviderId, + displayName: PROVIDER_LABELS[provider], windows, plan: planName, }; } + +/** @deprecated Use fetchGlmUsage instead */ +export async function fetchZaiUsage( + apiKey: string, + timeoutMs: number, + fetchFn: typeof fetch, +): Promise { + return fetchGlmUsage("zai", apiKey, timeoutMs, fetchFn); +} diff --git a/src/infra/provider-usage.load.ts b/src/infra/provider-usage.load.ts index 39a97a86c..c026df46b 100644 --- a/src/infra/provider-usage.load.ts +++ b/src/infra/provider-usage.load.ts @@ -5,8 +5,8 @@ import { fetchCodexUsage, fetchCopilotUsage, fetchGeminiUsage, + fetchGlmUsage, fetchMinimaxUsage, - fetchZaiUsage, } from "./provider-usage.fetch.js"; import { DEFAULT_TIMEOUT_MS, @@ -67,7 +67,10 @@ export async function loadProviderUsageSummary( case "minimax": return await fetchMinimaxUsage(auth.token, timeoutMs, fetchFn); case "zai": - return await fetchZaiUsage(auth.token, timeoutMs, fetchFn); + case "zai-coding": + case "zhipu": + case "zhipu-coding": + return await fetchGlmUsage(auth.provider, auth.token, timeoutMs, fetchFn); default: return { provider: auth.provider, diff --git a/src/infra/provider-usage.test.ts b/src/infra/provider-usage.test.ts index f5f95749a..a39e81ddc 100644 --- a/src/infra/provider-usage.test.ts +++ b/src/infra/provider-usage.test.ts @@ -137,7 +137,6 @@ describe("provider usage loading", () => { expect(mockFetch).toHaveBeenCalled(); }); - // TODO: Implement fetchZhipuUsage in provider-usage.load.ts it("loads zhipu usage from bigmodel.cn endpoint", async () => { const makeResponse = (status: number, body: unknown): Response => { const payload = typeof body === "string" ? body : JSON.stringify(body); @@ -180,6 +179,164 @@ describe("provider usage loading", () => { expect(zhipu?.windows[0]?.usedPercent).toBe(30); }); + it("returns error snapshot when GLM API returns HTTP error", async () => { + const makeResponse = (status: number, body: unknown): Response => { + const payload = typeof body === "string" ? body : JSON.stringify(body); + const headers = typeof body === "string" ? undefined : { "Content-Type": "application/json" }; + return new Response(payload, { status, headers }); + }; + + const mockFetch = vi.fn, ReturnType>(async (input) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("api.z.ai")) { + return makeResponse(500, "Internal Server Error"); + } + return makeResponse(404, "not found"); + }); + + const summary = await loadProviderUsageSummary({ + now: Date.UTC(2026, 0, 7, 0, 0, 0), + auth: [{ provider: "zai", token: "token-1" }], + fetch: mockFetch, + }); + + const zai = summary.providers.find((p) => p.provider === "zai"); + expect(zai?.error).toBe("HTTP 500"); + expect(zai?.windows).toHaveLength(0); + }); + + it("returns error snapshot when GLM API returns non-200 code", async () => { + const makeResponse = (status: number, body: unknown): Response => { + const payload = typeof body === "string" ? body : JSON.stringify(body); + const headers = typeof body === "string" ? undefined : { "Content-Type": "application/json" }; + return new Response(payload, { status, headers }); + }; + + const mockFetch = vi.fn, ReturnType>(async (input) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("api.z.ai")) { + return makeResponse(200, { + success: false, + code: 401, + msg: "Invalid API key", + }); + } + return makeResponse(404, "not found"); + }); + + const summary = await loadProviderUsageSummary({ + now: Date.UTC(2026, 0, 7, 0, 0, 0), + auth: [{ provider: "zai", token: "invalid-token" }], + fetch: mockFetch, + }); + + const zai = summary.providers.find((p) => p.provider === "zai"); + expect(zai?.error).toBe("Invalid API key"); + expect(zai?.windows).toHaveLength(0); + }); + + it("handles GLM TIME_LIMIT as Monthly window", async () => { + const makeResponse = (status: number, body: unknown): Response => { + const payload = typeof body === "string" ? body : JSON.stringify(body); + const headers = typeof body === "string" ? undefined : { "Content-Type": "application/json" }; + return new Response(payload, { status, headers }); + }; + + const mockFetch = vi.fn, ReturnType>(async (input) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("api.z.ai")) { + return makeResponse(200, { + success: true, + code: 200, + data: { + planName: "Enterprise", + limits: [ + { + type: "TIME_LIMIT", + percentage: 45, + unit: 1, + number: 30, + nextResetTime: "2026-02-01T00:00:00Z", + }, + ], + }, + }); + } + return makeResponse(404, "not found"); + }); + + const summary = await loadProviderUsageSummary({ + now: Date.UTC(2026, 0, 7, 0, 0, 0), + auth: [{ provider: "zai", token: "token-1" }], + fetch: mockFetch, + }); + + const zai = summary.providers.find((p) => p.provider === "zai"); + expect(zai?.plan).toBe("Enterprise"); + expect(zai?.windows).toHaveLength(1); + expect(zai?.windows[0]?.label).toBe("Monthly"); + expect(zai?.windows[0]?.usedPercent).toBe(45); + }); + + it("handles GLM response with both TOKENS_LIMIT and TIME_LIMIT", async () => { + const makeResponse = (status: number, body: unknown): Response => { + const payload = typeof body === "string" ? body : JSON.stringify(body); + const headers = typeof body === "string" ? undefined : { "Content-Type": "application/json" }; + return new Response(payload, { status, headers }); + }; + + const mockFetch = vi.fn, ReturnType>(async (input) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("api.z.ai")) { + return makeResponse(200, { + success: true, + code: 200, + data: { + planName: "Pro", + limits: [ + { + type: "TOKENS_LIMIT", + percentage: 25, + unit: 3, + number: 24, + nextResetTime: "2026-01-08T00:00:00Z", + }, + { + type: "TIME_LIMIT", + percentage: 10, + unit: 1, + number: 30, + nextResetTime: "2026-02-01T00:00:00Z", + }, + ], + }, + }); + } + return makeResponse(404, "not found"); + }); + + const summary = await loadProviderUsageSummary({ + now: Date.UTC(2026, 0, 7, 0, 0, 0), + auth: [{ provider: "zai", token: "token-1" }], + fetch: mockFetch, + }); + + const zai = summary.providers.find((p) => p.provider === "zai"); + expect(zai?.plan).toBe("Pro"); + expect(zai?.windows).toHaveLength(2); + + const tokensWindow = zai?.windows.find((w) => w.label.startsWith("Tokens")); + expect(tokensWindow?.label).toBe("Tokens (24h)"); + expect(tokensWindow?.usedPercent).toBe(25); + + const monthlyWindow = zai?.windows.find((w) => w.label === "Monthly"); + expect(monthlyWindow?.usedPercent).toBe(10); + }); + it("handles nested MiniMax usage payloads", async () => { const makeResponse = (status: number, body: unknown): Response => { const payload = typeof body === "string" ? body : JSON.stringify(body);