From 68e07b3735f50ee9aee2c0c23b95bacc969b6900 Mon Sep 17 00:00:00 2001 From: "musashi.hadano" Date: Sun, 1 Mar 2026 18:08:38 +0900 Subject: [PATCH 1/3] feat(zai): add weekly usage tracking support Add support for Z.ai's weekly usage limit (unit: 6) to track token usage on a 7-day rolling period. Changes: - Add WEEK_MS constant for 7-day period - Update findLimit() to accept optional unit parameter - Add Weekly progress line for TOKENS_LIMIT with unit: 6 - Update plugin.json with Weekly line definition - Add tests for Weekly line rendering - Update documentation Closes #242 Co-Authored-By: Claude Opus 4.6 --- docs/providers/zai.md | 7 +++- plugins/zai/plugin.js | 28 +++++++++++++- plugins/zai/plugin.json | 3 +- plugins/zai/plugin.test.js | 79 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 112 insertions(+), 5 deletions(-) diff --git a/docs/providers/zai.md b/docs/providers/zai.md index 2d75d4e8..9aeed7ed 100644 --- a/docs/providers/zai.md +++ b/docs/providers/zai.md @@ -11,8 +11,9 @@ Tracks [Z.ai](https://z.ai) (Zhipu AI) usage quotas for GLM coding plans. - **Base URL:** `https://api.z.ai/` - **Auth:** API key via environment variable (`ZAI_API_KEY`, fallback `GLM_API_KEY`) - **Session utilization:** percentage (0-100) +- **Weekly utilization:** percentage (0-100) - **Web searches:** count-based (used / limit) -- **Reset periods:** 5 hours (session), monthly (web searches, from subscription renewal date) +- **Reset periods:** 5 hours (session), 7 days (weekly), monthly (web searches, from subscription renewal date) ## Setup @@ -148,7 +149,8 @@ Returns session token usage and web search quotas. - `remaining` — tokens remaining - `percentage` — usage as percentage (0-100) - `nextResetTime` — epoch milliseconds of next reset -- `unit: 3, number: 5` — 5-hour rolling period +- `unit: 3, number: 5` — 5-hour rolling period (session) +- `unit: 6, number: 7` — 7-day rolling period (weekly) **TIME_LIMIT:** @@ -164,6 +166,7 @@ Returns session token usage and web search quotas. | Line | Description | |--------------|------------------------------------------------------------------------------| | Session | Token usage as percentage (0-100%) with 5h reset timer | +| Weekly | Token usage as percentage (0-100%) with 7-day reset timer | | Web Searches | Web search/reader call count (used / limit), resets on the 1st of each month | ## Errors diff --git a/plugins/zai/plugin.js b/plugins/zai/plugin.js index 7fbfd404..cc272103 100644 --- a/plugins/zai/plugin.js +++ b/plugins/zai/plugin.js @@ -3,6 +3,7 @@ const SUBSCRIPTION_URL = BASE_URL + "/api/biz/subscription/list" const QUOTA_URL = BASE_URL + "/api/monitor/usage/quota/limit" const PERIOD_MS = 5 * 60 * 60 * 1000 + const WEEK_MS = 7 * 24 * 60 * 60 * 1000 const MONTH_MS = 30 * 24 * 60 * 60 * 1000 function loadApiKey(ctx) { @@ -77,9 +78,14 @@ return data } - function findLimit(limits, type) { + function findLimit(limits, type, unit) { for (let i = 0; i < limits.length; i++) { - if (limits[i].type === type || limits[i].name === type) return limits[i] + const item = limits[i] + if (item.type === type || item.name === type) { + if (unit === undefined || item.unit === unit) { + return item + } + } } return null } @@ -125,6 +131,24 @@ } lines.push(ctx.line.progress(progressOpts)) + const weeklyTokenLimit = findLimit(limits, "TOKENS_LIMIT", 6) + if (weeklyTokenLimit) { + const weeklyUsed = typeof weeklyTokenLimit.percentage === "number" ? weeklyTokenLimit.percentage : 0 + const weeklyResetsAt = weeklyTokenLimit.nextResetTime ? ctx.util.toIso(weeklyTokenLimit.nextResetTime) : undefined + + const weeklyOpts = { + label: "Weekly", + used: weeklyUsed, + limit: 100, + format: { kind: "percent" }, + periodDurationMs: WEEK_MS, + } + if (weeklyResetsAt) { + weeklyOpts.resetsAt = weeklyResetsAt + } + lines.push(ctx.line.progress(weeklyOpts)) + } + const timeLimit = findLimit(limits, "TIME_LIMIT") if (timeLimit) { diff --git a/plugins/zai/plugin.json b/plugins/zai/plugin.json index 5e593f59..fc26cd61 100644 --- a/plugins/zai/plugin.json +++ b/plugins/zai/plugin.json @@ -8,6 +8,7 @@ "brandColor": "#2D2D2D", "lines": [ { "type": "progress", "label": "Session", "scope": "overview", "primaryOrder": 1 }, - { "type": "progress", "label": "Web Searches", "scope": "overview" } + { "type": "progress", "label": "Web Searches", "scope": "overview" }, + { "type": "progress", "label": "Weekly", "scope": "overview" } ] } diff --git a/plugins/zai/plugin.test.js b/plugins/zai/plugin.test.js index b6afc2e5..0326f49f 100644 --- a/plugins/zai/plugin.test.js +++ b/plugins/zai/plugin.test.js @@ -41,6 +41,46 @@ const QUOTA_RESPONSE = { }, } +const QUOTA_RESPONSE_WITH_WEEKLY = { + code: 200, + data: { + limits: [ + { + type: "TOKENS_LIMIT", + usage: 800000000, + currentValue: 1900000, + percentage: 10, + nextResetTime: 1738368000000, + unit: 3, + number: 5, + }, + { + type: "TOKENS_LIMIT", + usage: 1600000000, + currentValue: 4800000, + percentage: 10, + nextResetTime: 1738972800000, + unit: 6, + number: 7, + }, + { + type: "TIME_LIMIT", + usage: 4000, + currentValue: 1095, + percentage: 27, + remaining: 2905, + usageDetails: [ + { modelCode: "search-prime", usage: 951 }, + { modelCode: "web-reader", usage: 211 }, + { modelCode: "zread", usage: 0 }, + ], + unit: 5, + number: 1, + }, + ], + }, +} + const QUOTA_RESPONSE_NO_TIME_LIMIT = { code: 200, data: { @@ -393,4 +433,43 @@ describe("zai plugin", () => { expect(result.lines).toHaveLength(1) expect(result.lines[0].text).toBe("No usage data") }) + + it("renders Weekly line with percent format and 7-day reset", async () => { + const ctx = makeCtx() + mockEnvWithKey(ctx, "test-key") + ctx.host.http.request.mockImplementation((opts) => { + if (opts.url.includes("subscription")) { + return { status: 200, bodyText: JSON.stringify(SUBSCRIPTION_RESPONSE) } + } + return { status: 200, bodyText: JSON.stringify(QUOTA_RESPONSE_WITH_WEEKLY) } + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + const line = result.lines.find((l) => l.label === "Weekly") + expect(line).toBeTruthy() + expect(line.type).toBe("progress") + expect(line.used).toBe(10) + expect(line.limit).toBe(100) + expect(line.format).toEqual({ kind: "percent" }) + expect(line.periodDurationMs).toBe(7 * 24 * 60 * 60 * 1000) + }) + + it("Weekly line has correct percentage, resetsAt, and periodDurationMs values", async () => { + const ctx = makeCtx() + mockEnvWithKey(ctx, "test-key") + ctx.host.http.request.mockImplementation((opts) => { + if (opts.url.includes("subscription")) { + return { status: 200, bodyText: JSON.stringify(SUBSCRIPTION_RESPONSE) } + } + return { status: 200, bodyText: JSON.stringify(QUOTA_RESPONSE_WITH_WEEKLY) } + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + const line = result.lines.find((l) => l.label === "Weekly") + expect(line).toBeTruthy() + expect(line.resetsAt).toBe(new Date(1738972800000).toISOString()) + expect(line.periodDurationMs).toBe(7 * 24 * 60 * 60 * 1000) + }) }) From fbd6bcdf99795d83a09b1c8c2526844b0ddc5ab0 Mon Sep 17 00:00:00 2001 From: "musashi.hadano" Date: Sun, 1 Mar 2026 18:29:12 +0900 Subject: [PATCH 2/3] fix: README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a51e98fb..a4feedea 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ OpenUsage lives in your menu bar and shows you how much of your AI coding subscr - [**Kimi Code**](docs/providers/kimi.md) / session, weekly - [**MiniMax**](docs/providers/minimax.md) / coding plan session - [**Windsurf**](docs/providers/windsurf.md) / prompt credits, flex credits -- [**Z.ai**](docs/providers/zai.md) / session, web searches +- [**Z.ai**](docs/providers/zai.md) / session, weekly, web searches ### Maybe Soon From 59df9a5220d65f50adfc9f89584fe69225cbb262 Mon Sep 17 00:00:00 2001 From: "musashi.hadano" Date: Mon, 2 Mar 2026 19:17:05 +0900 Subject: [PATCH 3/3] fix(zai): address PR review feedback for weekly usage tracking - Reorder lines in plugin.json to match runtime output order (Session -> Weekly -> Web Searches) - Make findLimit unit-aware for Session to correctly bind to unit 3, with fallback to first entry without unit - Add Number.isFinite check for weeklyTokenLimit.percentage - Add test case for weekly appearing before session in API response Co-Authored-By: Claude Opus 4.6 --- plugins/zai/plugin.js | 16 ++++++++--- plugins/zai/plugin.json | 4 +-- plugins/zai/plugin.test.js | 58 +++++++++++++++++++++++++++++++++++++- 3 files changed, 71 insertions(+), 7 deletions(-) diff --git a/plugins/zai/plugin.js b/plugins/zai/plugin.js index cc272103..11e4f75c 100644 --- a/plugins/zai/plugin.js +++ b/plugins/zai/plugin.js @@ -79,15 +79,23 @@ } function findLimit(limits, type, unit) { + let fallback = null for (let i = 0; i < limits.length; i++) { const item = limits[i] if (item.type === type || item.name === type) { - if (unit === undefined || item.unit === unit) { + if (unit === undefined) { return item } + if (item.unit === unit) { + return item + } + // Store first entry without unit as fallback + if (fallback === null && item.unit === undefined) { + fallback = item + } } } - return null + return fallback } function probe(ctx) { @@ -109,7 +117,7 @@ return { plan, lines } } - const tokenLimit = findLimit(limits, "TOKENS_LIMIT") + const tokenLimit = findLimit(limits, "TOKENS_LIMIT", 3) if (!tokenLimit) { lines.push(ctx.line.badge({ label: "Session", text: "No usage data", color: "#a3a3a3" })) @@ -133,7 +141,7 @@ const weeklyTokenLimit = findLimit(limits, "TOKENS_LIMIT", 6) if (weeklyTokenLimit) { - const weeklyUsed = typeof weeklyTokenLimit.percentage === "number" ? weeklyTokenLimit.percentage : 0 + const weeklyUsed = Number.isFinite(weeklyTokenLimit.percentage) ? weeklyTokenLimit.percentage : 0 const weeklyResetsAt = weeklyTokenLimit.nextResetTime ? ctx.util.toIso(weeklyTokenLimit.nextResetTime) : undefined const weeklyOpts = { diff --git a/plugins/zai/plugin.json b/plugins/zai/plugin.json index fc26cd61..b60b0cbb 100644 --- a/plugins/zai/plugin.json +++ b/plugins/zai/plugin.json @@ -8,7 +8,7 @@ "brandColor": "#2D2D2D", "lines": [ { "type": "progress", "label": "Session", "scope": "overview", "primaryOrder": 1 }, - { "type": "progress", "label": "Web Searches", "scope": "overview" }, - { "type": "progress", "label": "Weekly", "scope": "overview" } + { "type": "progress", "label": "Weekly", "scope": "overview" }, + { "type": "progress", "label": "Web Searches", "scope": "overview" } ] } diff --git a/plugins/zai/plugin.test.js b/plugins/zai/plugin.test.js index 0326f49f..86c452a1 100644 --- a/plugins/zai/plugin.test.js +++ b/plugins/zai/plugin.test.js @@ -403,7 +403,7 @@ describe("zai plugin", () => { return { status: 200, bodyText: JSON.stringify([ - { type: "TOKENS_LIMIT", percentage: "10", nextResetTime: 1738368000000 }, + { type: "TOKENS_LIMIT", percentage: "10", nextResetTime: 1738368000000, unit: 3 }, { type: "TIME_LIMIT", currentValue: "1095", usage: "4000" }, ]), } @@ -472,4 +472,60 @@ describe("zai plugin", () => { expect(line.resetsAt).toBe(new Date(1738972800000).toISOString()) expect(line.periodDurationMs).toBe(7 * 24 * 60 * 60 * 1000) }) + + it("correctly binds Session to unit 3 and Weekly to unit 6 when weekly appears first", async () => { + const ctx = makeCtx() + mockEnvWithKey(ctx, "test-key") + const quotaReversed = { + code: 200, + data: { + limits: [ + { + type: "TOKENS_LIMIT", + usage: 1600000000, + currentValue: 4800000, + percentage: 75, + nextResetTime: 1738972800000, + unit: 6, + number: 7, + }, + { + type: "TOKENS_LIMIT", + usage: 800000000, + currentValue: 1900000, + percentage: 10, + nextResetTime: 1738368000000, + unit: 3, + number: 5, + }, + { + type: "TIME_LIMIT", + usage: 4000, + currentValue: 1095, + percentage: 27, + remaining: 2905, + unit: 5, + number: 1, + }, + ], + }, + } + ctx.host.http.request.mockImplementation((opts) => { + if (opts.url.includes("subscription")) { + return { status: 200, bodyText: JSON.stringify(SUBSCRIPTION_RESPONSE) } + } + return { status: 200, bodyText: JSON.stringify(quotaReversed) } + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + const session = result.lines.find((l) => l.label === "Session") + const weekly = result.lines.find((l) => l.label === "Weekly") + expect(session).toBeTruthy() + expect(session.used).toBe(10) + expect(session.resetsAt).toBe(new Date(1738368000000).toISOString()) + expect(weekly).toBeTruthy() + expect(weekly.used).toBe(75) + expect(weekly.resetsAt).toBe(new Date(1738972800000).toISOString()) + }) })