Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 5 additions & 2 deletions docs/providers/zai.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:**

Expand All @@ -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
Expand Down
40 changes: 36 additions & 4 deletions plugins/zai/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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" }))
Expand All @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions plugins/zai/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
]
}
137 changes: 136 additions & 1 deletion plugins/zai/plugin.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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" },
]),
}
Expand Down Expand Up @@ -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())
})
})