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 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..11e4f75c 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,11 +78,24 @@ return data } - function findLimit(limits, type) { + function findLimit(limits, type, unit) { + let fallback = null 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) { + 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) { @@ -103,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" })) @@ -125,6 +139,24 @@ } lines.push(ctx.line.progress(progressOpts)) + const weeklyTokenLimit = findLimit(limits, "TOKENS_LIMIT", 6) + if (weeklyTokenLimit) { + const weeklyUsed = Number.isFinite(weeklyTokenLimit.percentage) ? 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..b60b0cbb 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": "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 b6afc2e5..86c452a1 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: { @@ -363,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" }, ]), } @@ -393,4 +433,99 @@ 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) + }) + + 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()) + }) })