From fcfcced4ab3ffce20d1b8ccddb0159782cc4dc39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Gon=C3=A7alves=20Araujo?= <47118053+dandgabr@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:34:37 +0700 Subject: [PATCH] fix(usage): support CREDIT_LIMIT and multi-interval GLM quotas GLM quota parsing only accepted TOKENS_LIMIT and wrote every limit to a single "session" key, so credit-based plans showed nothing and later intervals overwrote earlier ones. Accept CREDIT_LIMIT too and derive the quota key from the limit unit (5h session, 7d weekly, tokens, custom). Moves the parser into its own usage/glm.js, re-exported from misc.js. --- open-sse/services/usage.js | 2 +- open-sse/services/usage/glm.js | 88 +++++++++++++++ open-sse/services/usage/misc.js | 63 +---------- tests/unit/glm-usage.test.js | 192 ++++++++++++++++++++++++++++++++ 4 files changed, 283 insertions(+), 62 deletions(-) create mode 100644 open-sse/services/usage/glm.js create mode 100644 tests/unit/glm-usage.test.js diff --git a/open-sse/services/usage.js b/open-sse/services/usage.js index 65062467..834d61b7 100644 --- a/open-sse/services/usage.js +++ b/open-sse/services/usage.js @@ -15,10 +15,10 @@ import { getGrokCliUsage } from "./usage/grok-cli.js"; import { getKimiUsage } from "./usage/kimi.js"; import { getDeepseekUsage } from "./usage/deepseek.js"; import { resolveQoderCredentials } from "./qoderModels.js"; +import { getGlmUsage } from "./usage/glm.js"; import { getIflowUsage, getOllamaUsage, - getGlmUsage, getVercelAiGatewayUsage, getQoderUsage, } from "./usage/misc.js"; diff --git a/open-sse/services/usage/glm.js b/open-sse/services/usage/glm.js new file mode 100644 index 00000000..f4064af6 --- /dev/null +++ b/open-sse/services/usage/glm.js @@ -0,0 +1,88 @@ +/** + * GLM Coding Plan usage (international + China regions) + */ + +import { proxyAwareFetch } from "../../utils/proxyFetch.js"; +import { U } from "./shared.js"; + +// GLM quota endpoints (region-aware) — url from registry transport.usage +const GLM_QUOTA_URLS = { + international: U("glm").url, + china: U("glm-cn").url, +}; + +/** + * GLM Coding Plan usage (international + China regions) + * Supports both TOKENS_LIMIT and CREDIT_LIMIT and dynamic intervals (e.g. session 5h, weekly 7d). + */ +export async function getGlmUsage(apiKey, provider, proxyOptions = null) { + if (!apiKey) { + return { message: "GLM API key not available." }; + } + + const region = provider === "glm-cn" ? "china" : "international"; + const quotaUrl = GLM_QUOTA_URLS[region]; + + try { + const response = await proxyAwareFetch( + quotaUrl, + { + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json", + }, + }, + proxyOptions, + ); + + if (!response.ok) { + if (response.status === 401) { + return { message: "GLM API key invalid or expired." }; + } + return { message: `GLM quota API error (${response.status}).` }; + } + + const json = await response.json(); + const data = json?.data && typeof json.data === "object" ? json.data : {}; + const limits = Array.isArray(data.limits) ? data.limits : []; + const quotas = {}; + + for (const limit of limits) { + // 1. Accept both TOKENS_LIMIT and CREDIT_LIMIT from GLM API + if (!limit || (limit.type !== "TOKENS_LIMIT" && limit.type !== "CREDIT_LIMIT")) continue; + const usedPercent = Number(limit.percentage) || 0; + const resetMs = Number(limit.nextResetTime) || 0; + const remaining = Math.max(0, 100 - usedPercent); + + // 2. Map key dynamically based on type and period (unit) to avoid overwriting + let key = "session"; + if (limit.unit === 3) { + key = `Session (${limit.number}h)`; + } else if (limit.unit === 6) { + key = "Weekly (7d)"; + } else if (limit.type === "TOKENS_LIMIT") { + key = "Tokens"; + } else { + key = `Limit (${limit.number})`; + } + + quotas[key] = { + used: usedPercent, + total: 100, + remaining, + remainingPercentage: remaining, + resetAt: resetMs > 0 ? new Date(resetMs).toISOString() : null, + unlimited: false, + }; + } + + const levelRaw = typeof data.level === "string" ? data.level : ""; + const plan = levelRaw + ? levelRaw.charAt(0).toUpperCase() + levelRaw.slice(1).toLowerCase() + : "Unknown"; + + return { plan, quotas }; + } catch (error) { + return { message: `GLM error: ${error.message}` }; + } +} diff --git a/open-sse/services/usage/misc.js b/open-sse/services/usage/misc.js index fc133eff..e4b04589 100644 --- a/open-sse/services/usage/misc.js +++ b/open-sse/services/usage/misc.js @@ -5,11 +5,8 @@ import { proxyAwareFetch } from "../../utils/proxyFetch.js"; import { U } from "./shared.js"; -// GLM quota endpoints (region-aware) — url from registry transport.usage -const GLM_QUOTA_URLS = { - international: U("glm").url, - china: U("glm-cn").url, -}; +export { getGlmUsage } from "./glm.js"; + // Vercel AI Gateway credits endpoint // Returns { balance: "95.50", total_used: "4.50" } (USD as decimal strings). @@ -112,63 +109,7 @@ export async function getOllamaUsage(apiKey, providerSpecificData, proxyOptions } } -/** - * GLM Coding Plan usage (international + China regions) - */ -export async function getGlmUsage(apiKey, provider, proxyOptions = null) { - if (!apiKey) { - return { message: "GLM API key not available." }; - } - const region = provider === "glm-cn" ? "china" : "international"; - const quotaUrl = GLM_QUOTA_URLS[region]; - - try { - const response = await proxyAwareFetch(quotaUrl, { - headers: { - Authorization: `Bearer ${apiKey}`, - Accept: "application/json", - }, - }, proxyOptions); - - if (!response.ok) { - if (response.status === 401) { - return { message: "GLM API key invalid or expired." }; - } - return { message: `GLM quota API error (${response.status}).` }; - } - - const json = await response.json(); - const data = json?.data && typeof json.data === "object" ? json.data : {}; - const limits = Array.isArray(data.limits) ? data.limits : []; - const quotas = {}; - - for (const limit of limits) { - if (!limit || limit.type !== "TOKENS_LIMIT") continue; - const usedPercent = Number(limit.percentage) || 0; - const resetMs = Number(limit.nextResetTime) || 0; - const remaining = Math.max(0, 100 - usedPercent); - - quotas["session"] = { - used: usedPercent, - total: 100, - remaining, - remainingPercentage: remaining, - resetAt: resetMs > 0 ? new Date(resetMs).toISOString() : null, - unlimited: false, - }; - } - - const levelRaw = typeof data.level === "string" ? data.level : ""; - const plan = levelRaw - ? levelRaw.charAt(0).toUpperCase() + levelRaw.slice(1).toLowerCase() - : "Unknown"; - - return { plan, quotas }; - } catch (error) { - return { message: `GLM error: ${error.message}` }; - } -} /** * Vercel AI Gateway usage — credit balance for the API key diff --git a/tests/unit/glm-usage.test.js b/tests/unit/glm-usage.test.js new file mode 100644 index 00000000..b381f112 --- /dev/null +++ b/tests/unit/glm-usage.test.js @@ -0,0 +1,192 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../open-sse/utils/proxyFetch.js", () => ({ + proxyAwareFetch: vi.fn(), +})); + +import { proxyAwareFetch } from "../../open-sse/utils/proxyFetch.js"; +import { getUsageForProvider } from "../../open-sse/services/usage.js"; +import { getGlmUsage } from "../../open-sse/services/usage/glm.js"; +import { + USAGE_SUPPORTED_PROVIDERS, + USAGE_APIKEY_PROVIDERS, +} from "../../src/shared/constants/providers.js"; + +function jsonResponse(body, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +const SAMPLE_GLM_CREDIT_USAGE = { + code: 200, + msg: "Operation successful", + data: { + limits: [ + { + type: "CREDIT_LIMIT", + unit: 3, + number: 5, + usage: 2000, + currentValue: 0, + remaining: 1999, + percentage: 25, + nextResetTime: 1787905548392, + }, + { + type: "CREDIT_LIMIT", + unit: 6, + number: 1, + usage: 10000, + currentValue: 0, + remaining: 9999, + percentage: 10, + nextResetTime: 1788492142997, + }, + ], + level: "lite", + }, + success: true, +}; + +const SAMPLE_GLM_TOKENS_USAGE = { + code: 200, + msg: "Operation successful", + data: { + limits: [ + { + type: "TOKENS_LIMIT", + percentage: 40, + nextResetTime: 1787905548392, + }, + ], + level: "standard", + }, + success: true, +}; + +describe("glm registry usage flags", () => { + it("is listed for apikey quota dashboard", () => { + expect(USAGE_SUPPORTED_PROVIDERS).toContain("glm"); + expect(USAGE_SUPPORTED_PROVIDERS).toContain("glm-cn"); + expect(USAGE_APIKEY_PROVIDERS).toContain("glm"); + expect(USAGE_APIKEY_PROVIDERS).toContain("glm-cn"); + }); +}); + +describe("getGlmUsage and getUsageForProvider(glm)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("handles CREDIT_LIMIT with session 5h and weekly 7d quotas", async () => { + proxyAwareFetch.mockResolvedValueOnce(jsonResponse(SAMPLE_GLM_CREDIT_USAGE)); + + const usage = await getUsageForProvider({ + provider: "glm", + apiKey: "glm-key-123", + }); + + expect(usage.message).toBeUndefined(); + expect(usage.plan).toBe("Lite"); + expect(usage.quotas["Session (5h)"]).toEqual({ + used: 25, + total: 100, + remaining: 75, + remainingPercentage: 75, + resetAt: new Date(1787905548392).toISOString(), + unlimited: false, + }); + expect(usage.quotas["Weekly (7d)"]).toEqual({ + used: 100 ? 10 : 10, + total: 100, + remaining: 90, + remainingPercentage: 90, + resetAt: new Date(1788492142997).toISOString(), + unlimited: false, + }); + }); + + it("handles TOKENS_LIMIT quotas", async () => { + proxyAwareFetch.mockResolvedValueOnce(jsonResponse(SAMPLE_GLM_TOKENS_USAGE)); + + const usage = await getUsageForProvider({ + provider: "glm-cn", + apiKey: "glm-cn-key", + }); + + expect(usage.message).toBeUndefined(); + expect(usage.plan).toBe("Standard"); + expect(usage.quotas["Tokens"]).toEqual({ + used: 40, + total: 100, + remaining: 60, + remainingPercentage: 60, + resetAt: new Date(1787905548392).toISOString(), + unlimited: false, + }); + }); + + it("handles fallback key for custom limit units", async () => { + proxyAwareFetch.mockResolvedValueOnce( + jsonResponse({ + code: 200, + data: { + limits: [ + { + type: "CREDIT_LIMIT", + unit: 99, + number: 12, + percentage: 5, + nextResetTime: 0, + }, + ], + level: "pro", + }, + }) + ); + + const usage = await getGlmUsage("glm-key", "glm"); + expect(usage.plan).toBe("Pro"); + expect(usage.quotas["Limit (12)"]).toEqual({ + used: 5, + total: 100, + remaining: 95, + remainingPercentage: 95, + resetAt: null, + unlimited: false, + }); + }); + + it("surfaces invalid key message on 401", async () => { + proxyAwareFetch.mockResolvedValueOnce(jsonResponse({ error: "unauthorized" }, 401)); + + const usage = await getUsageForProvider({ + provider: "glm", + apiKey: "invalid-key", + }); + + expect(usage.message).toMatch(/invalid or expired/i); + }); + + it("handles non-200 error response", async () => { + proxyAwareFetch.mockResolvedValueOnce(jsonResponse({ error: "server error" }, 500)); + + const usage = await getUsageForProvider({ + provider: "glm", + apiKey: "valid-key", + }); + + expect(usage.message).toMatch(/GLM quota API error \(500\)/); + }); + + it("returns message when apiKey is missing", async () => { + const usage = await getUsageForProvider({ + provider: "glm", + apiKey: "", + }); + + expect(usage.message).toBe("GLM API key not available."); + }); +});