@v1nvn/zai 0.45.0 → 0.45.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- package/package.json +3 -3
- package/src/format.js +410 -0
- package/src/index.js +16 -0
- package/src/index.ts +0 -0
- package/src/resolve.js +99 -0
- package/src/usage.js +87 -0
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ claude plugin install zai@agentic
|
|
|
22
22
|
In a terminal, bare:
|
|
23
23
|
|
|
24
24
|
```sh
|
|
25
|
-
npx -y @v1nvn/zai@0.45.
|
|
25
|
+
npx -y @v1nvn/zai@0.45.1 usage
|
|
26
26
|
```
|
|
27
27
|
|
|
28
28
|
## Usage
|
|
@@ -30,10 +30,10 @@ npx -y @v1nvn/zai@0.45.0 usage
|
|
|
30
30
|
| Invocation | Does |
|
|
31
31
|
|---|---|
|
|
32
32
|
| `/zai-usage` | the account's usage report, as a pane |
|
|
33
|
-
| `npx -y @v1nvn/zai@0.45.
|
|
34
|
-
| `npx -y @v1nvn/zai@0.45.
|
|
35
|
-
| `npx -y @v1nvn/zai@0.45.
|
|
36
|
-
| `npx -y @v1nvn/zai@0.45.
|
|
33
|
+
| `npx -y @v1nvn/zai@0.45.1 usage` | the same report, printed bare |
|
|
34
|
+
| `npx -y @v1nvn/zai@0.45.1 usage --auth-token TOKEN` | same, key on the command line (visible in `ps`) |
|
|
35
|
+
| `npx -y @v1nvn/zai@0.45.1 usage --auth-token=TOKEN` | `=` form — zsh quoting-safe |
|
|
36
|
+
| `npx -y @v1nvn/zai@0.45.1 usage --base-url URL` | another GLM endpoint (default `api.z.ai`) |
|
|
37
37
|
|
|
38
38
|
Each setting takes the first source that provides it:
|
|
39
39
|
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@v1nvn/zai",
|
|
3
|
-
"version": "0.45.
|
|
3
|
+
"version": "0.45.1",
|
|
4
4
|
"description": "Query GLM Coding Plan quota and usage — the zai-usage CLI behind the zai plugin.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"zai-usage": "./src/index.
|
|
7
|
+
"zai-usage": "./src/index.js"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
10
|
"test": "yarn run -T vitest run",
|
|
@@ -35,6 +35,6 @@
|
|
|
35
35
|
"node": ">=23.6"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@v1nvn/agentic-core": "0.45.
|
|
38
|
+
"@v1nvn/agentic-core": "0.45.1"
|
|
39
39
|
}
|
|
40
40
|
}
|
package/src/format.js
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usage-report line model: `reportLines` lays the report out once as ink-tagged
|
|
3
|
+
* segments (the pane draws those), and `render` joins them into the CLI's
|
|
4
|
+
* monospace terminal / hook-block `reason` bytes — no markdown; alignment comes
|
|
5
|
+
* from fixed-width columns and unicode block glyphs. Input is the parsed `data`
|
|
6
|
+
* of the three ZAI/ZHIPU monitor endpoints (model-usage, tool-usage,
|
|
7
|
+
* quota/limit — the last already passed through processQuotaLimit).
|
|
8
|
+
*/
|
|
9
|
+
import { bar, bold, dim, doc, fmtNum, fmtTokens, line, meter, MONTHS, pad, pad2, plain, rule, RULE_WIDTH, textOf, } from '@v1nvn/agentic-core/draw';
|
|
10
|
+
// Lower-half block eighths for vertical bars (1/8 .. 7/8); a full cell uses '█'.
|
|
11
|
+
const VBLOCKS = '▁▂▃▄▅▆▇';
|
|
12
|
+
function parseSlot(s) {
|
|
13
|
+
const m = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2})/.exec(s ?? '');
|
|
14
|
+
if (!m) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
const [, mo, d, hh, mm] = m.slice(1);
|
|
18
|
+
return {
|
|
19
|
+
h: +hh,
|
|
20
|
+
day: `${MONTHS[+mo - 1]} ${d}`,
|
|
21
|
+
time: `${hh}:${mm}`,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Re-express a naive "YYYY-MM-DD HH:MM" string from one UTC offset to another.
|
|
26
|
+
* Offsets are minutes east of UTC (Beijing = 480, IST = 330). Pure — does not
|
|
27
|
+
* depend on the runtime timezone, so it is deterministic under test.
|
|
28
|
+
*/
|
|
29
|
+
function shiftSlot(s, fromOffsetMin, toOffsetMin) {
|
|
30
|
+
const m = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2})/.exec(s);
|
|
31
|
+
if (!m) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
const [y, mo, d, hh, mm] = m.slice(1);
|
|
35
|
+
const utcMs = Date.UTC(+y, +mo - 1, +d, +hh, +mm) - fromOffsetMin * 60000;
|
|
36
|
+
const dt = new Date(utcMs + toOffsetMin * 60000);
|
|
37
|
+
return {
|
|
38
|
+
h: dt.getUTCHours(),
|
|
39
|
+
day: `${MONTHS[dt.getUTCMonth()]} ${pad2(dt.getUTCDate())}`,
|
|
40
|
+
time: `${pad2(dt.getUTCHours())}:${pad2(dt.getUTCMinutes())}`,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function friendlyTool(code) {
|
|
44
|
+
switch ((code ?? '').toLowerCase()) {
|
|
45
|
+
case 'search-prime':
|
|
46
|
+
return 'web search';
|
|
47
|
+
case 'web-reader':
|
|
48
|
+
return 'web read';
|
|
49
|
+
default:
|
|
50
|
+
return code || 'other';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function idleRuns(tok) {
|
|
54
|
+
const runs = [];
|
|
55
|
+
let i = 0;
|
|
56
|
+
while (i < tok.length) {
|
|
57
|
+
if ((tok[i] || 0) === 0) {
|
|
58
|
+
let j = i;
|
|
59
|
+
while (j < tok.length && (tok[j] || 0) === 0) {
|
|
60
|
+
j++;
|
|
61
|
+
}
|
|
62
|
+
runs.push({ i, j: j - 1, len: j - i });
|
|
63
|
+
i = j;
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
i++;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return runs;
|
|
70
|
+
}
|
|
71
|
+
function idleLabel(run, x, slotFn) {
|
|
72
|
+
const a = slotFn(x[run.i] ?? '');
|
|
73
|
+
const b = slotFn(x[run.j] ?? '');
|
|
74
|
+
if (!a || !b) {
|
|
75
|
+
return `idle (${run.len}h)`;
|
|
76
|
+
}
|
|
77
|
+
const start = `${a.day} ${a.time}`;
|
|
78
|
+
const end = a.day === b.day ? b.time : `${b.day} ${b.time}`;
|
|
79
|
+
return `idle ${start} → ${end} (${run.len}h)`;
|
|
80
|
+
}
|
|
81
|
+
// Per the usage-revision notice: Mon–Fri 14:00–18:00 Beijing (UTC+8) are peak
|
|
82
|
+
// hours, where GLM-5.2 / GLM-5-Turbo tokens are billed at a 3× coefficient
|
|
83
|
+
// (GLM-4.7 stays 1× all day). A bucket labelled "HH:00" spans HH:00–HH+1, so
|
|
84
|
+
// the peak buckets are HH ∈ {14,15,16,17} on a weekday.
|
|
85
|
+
function isPeakBucket(s) {
|
|
86
|
+
const m = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):/.exec(s ?? '');
|
|
87
|
+
if (!m) {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
const [y, mo, d, hh] = m.slice(1);
|
|
91
|
+
const wd = new Date(Date.UTC(+y, +mo - 1, +d)).getUTCDay(); // 0=Sun … 6=Sat
|
|
92
|
+
return wd >= 1 && wd <= 5 && +hh >= 14 && +hh <= 17;
|
|
93
|
+
}
|
|
94
|
+
/** Local-time start/end of the 14:00–18:00 Beijing peak window (e.g. 11:30 / 15:30). */
|
|
95
|
+
function localPeakWindow(apiOffsetMin, toOffsetMin) {
|
|
96
|
+
const a = shiftSlot('2000-01-03 14:00', apiOffsetMin, toOffsetMin);
|
|
97
|
+
const b = shiftSlot('2000-01-03 18:00', apiOffsetMin, toOffsetMin);
|
|
98
|
+
if (!a || !b) {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
return { start: a.time, end: b.time };
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Vertical bar chart of hourly tokens. Returns an array of plain-text lines
|
|
105
|
+
* (without the section header), or null if there are fewer than 2 buckets.
|
|
106
|
+
*
|
|
107
|
+
* Bars are scaled to `maxTok` across `ROWS` rows; the topmost filled cell of a
|
|
108
|
+
* bar uses a fractional lower-block glyph (▁▂▃▄▅▆▇) for sub-row height. The
|
|
109
|
+
* y-axis labels only the peak (top) and 0 (baseline); the header window and the
|
|
110
|
+
* caller's peak annotation carry the exact values.
|
|
111
|
+
*/
|
|
112
|
+
function hourlyVerticalChart({ x, tok, nh, maxTok, peakIdx, slotFn, }) {
|
|
113
|
+
const ROWS = 6; // bar rows above the baseline
|
|
114
|
+
if (nh < 2 || maxTok <= 0) {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
const lines = [];
|
|
118
|
+
const maxLab = fmtTokens(maxTok);
|
|
119
|
+
const L = maxLab.length;
|
|
120
|
+
const IND = 1; // leading indent
|
|
121
|
+
const AXIS = IND + L + 1; // axis spine col (1-space gap after the label)
|
|
122
|
+
const P0 = AXIS + 2; // first bar column (axis + 1-char gap)
|
|
123
|
+
const PW = RULE_WIDTH - P0; // columns available for bars + gaps
|
|
124
|
+
const slot = Math.max(1, Math.floor(PW / nh));
|
|
125
|
+
const barW = slot >= 2 ? slot - 1 : 1;
|
|
126
|
+
function barLeft(i) {
|
|
127
|
+
return P0 + i * slot;
|
|
128
|
+
}
|
|
129
|
+
function barCenter(i) {
|
|
130
|
+
return barLeft(i) + Math.floor(barW / 2);
|
|
131
|
+
}
|
|
132
|
+
const usedCols = nh * slot;
|
|
133
|
+
function blank() {
|
|
134
|
+
return Array(RULE_WIDTH).fill(' ');
|
|
135
|
+
}
|
|
136
|
+
// bar glyph for hour i at row r (0 = bottom bar-row, ROWS-1 = top)
|
|
137
|
+
function glyph(i, r) {
|
|
138
|
+
const v = tok[i] || 0;
|
|
139
|
+
if (v <= 0) {
|
|
140
|
+
return ' ';
|
|
141
|
+
}
|
|
142
|
+
const fill = (v / maxTok) * ROWS - r; // how much of row r the bar covers
|
|
143
|
+
if (fill >= 1) {
|
|
144
|
+
return '█';
|
|
145
|
+
}
|
|
146
|
+
if (fill > 0) {
|
|
147
|
+
let e = Math.round(fill * 8); // 1..7 eighths
|
|
148
|
+
if (e < 1) {
|
|
149
|
+
e = 1;
|
|
150
|
+
}
|
|
151
|
+
if (e > 7) {
|
|
152
|
+
e = 7;
|
|
153
|
+
}
|
|
154
|
+
return VBLOCKS[e - 1] ?? ' ';
|
|
155
|
+
}
|
|
156
|
+
return ' ';
|
|
157
|
+
}
|
|
158
|
+
// bar rows, top → bottom; top row carries the max label + '┤', rest are '│'
|
|
159
|
+
for (let r = ROWS - 1; r >= 0; r--) {
|
|
160
|
+
const line = blank();
|
|
161
|
+
line[AXIS] = r === ROWS - 1 ? '┤' : '│';
|
|
162
|
+
if (r === ROWS - 1) {
|
|
163
|
+
for (let k = 0; k < L; k++) {
|
|
164
|
+
line[AXIS - 1 - L + k] = maxLab[k] ?? ' ';
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
for (let i = 0; i < nh; i++) {
|
|
168
|
+
const g = glyph(i, r);
|
|
169
|
+
if (g !== ' ') {
|
|
170
|
+
for (let c = 0; c < barW; c++) {
|
|
171
|
+
line[barLeft(i) + c] = g;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
lines.push(line.join('').trimEnd());
|
|
176
|
+
}
|
|
177
|
+
// x-axis ticks: first, last, and every bucket landing on a 6h boundary (local)
|
|
178
|
+
function slotOf(i) {
|
|
179
|
+
return slotFn(x[i] ?? '');
|
|
180
|
+
}
|
|
181
|
+
function isTick(i) {
|
|
182
|
+
if (i === 0 || i === nh - 1) {
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
const s = slotOf(i);
|
|
186
|
+
return !!s && s.h % 6 === 0;
|
|
187
|
+
}
|
|
188
|
+
// baseline: '0' label + corner '└' + '─' run, with '┬' under each tick
|
|
189
|
+
const base = blank();
|
|
190
|
+
base[AXIS - 2] = '0';
|
|
191
|
+
base[AXIS] = '└';
|
|
192
|
+
for (let c = AXIS + 1; c < P0 + usedCols; c++) {
|
|
193
|
+
base[c] = '─';
|
|
194
|
+
}
|
|
195
|
+
for (let i = 0; i < nh; i++) {
|
|
196
|
+
if (isTick(i)) {
|
|
197
|
+
base[barCenter(i)] = '┬';
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
lines.push(base.join('').trimEnd());
|
|
201
|
+
// hour labels under ticks (skip if too close to the previous label)
|
|
202
|
+
const hr = blank();
|
|
203
|
+
let last = -10;
|
|
204
|
+
for (let i = 0; i < nh; i++) {
|
|
205
|
+
if (!isTick(i)) {
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
const col = barCenter(i);
|
|
209
|
+
if (col - last < 3) {
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
const s = slotOf(i);
|
|
213
|
+
const txt = s ? pad2(s.h) : ' ';
|
|
214
|
+
hr[col] = txt.charAt(0);
|
|
215
|
+
hr[col + 1] = txt.charAt(1);
|
|
216
|
+
last = col;
|
|
217
|
+
}
|
|
218
|
+
lines.push(hr.join('').trimEnd());
|
|
219
|
+
// day labels under the first tick of each new local day
|
|
220
|
+
const day = blank();
|
|
221
|
+
let lastDay = null;
|
|
222
|
+
last = -10;
|
|
223
|
+
for (let i = 0; i < nh; i++) {
|
|
224
|
+
if (!isTick(i)) {
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
const s = slotOf(i);
|
|
228
|
+
if (!s || s.day === lastDay) {
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
const col = barCenter(i);
|
|
232
|
+
if (col - last >= s.day.length && col + s.day.length <= RULE_WIDTH) {
|
|
233
|
+
for (let k = 0; k < s.day.length; k++) {
|
|
234
|
+
day[col + k] = s.day[k] ?? ' ';
|
|
235
|
+
}
|
|
236
|
+
lastDay = s.day;
|
|
237
|
+
last = col + s.day.length;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (day.some(c => c !== ' ')) {
|
|
241
|
+
lines.push(day.join('').trimEnd());
|
|
242
|
+
}
|
|
243
|
+
// peak-window (↑) and peak (◂) markers under bar centers
|
|
244
|
+
const mark = blank();
|
|
245
|
+
for (let i = 0; i < nh; i++) {
|
|
246
|
+
if (isPeakBucket(x[i])) {
|
|
247
|
+
mark[barCenter(i)] = '↑';
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (peakIdx >= 0) {
|
|
251
|
+
mark[barCenter(peakIdx)] = '◂';
|
|
252
|
+
}
|
|
253
|
+
if (mark.some(c => c !== ' ')) {
|
|
254
|
+
lines.push(mark.join('').trimEnd());
|
|
255
|
+
}
|
|
256
|
+
return lines;
|
|
257
|
+
}
|
|
258
|
+
export function reportLines({ platform, model, tool, quota, apiOffsetMin = 480, localOffsetMin, now = new Date(), }) {
|
|
259
|
+
const out = [];
|
|
260
|
+
// The monitor API labels every bucket in Beijing time (UTC+8). Shift each
|
|
261
|
+
// timestamp to the caller's offset (minutes east of UTC) for display.
|
|
262
|
+
// Defaults to the JS runtime's local zone.
|
|
263
|
+
const toOffset = localOffsetMin != null ? localOffsetMin : -new Date().getTimezoneOffset();
|
|
264
|
+
function convertSlot(s) {
|
|
265
|
+
return shiftSlot(s, apiOffsetMin, toOffset) ?? parseSlot(s);
|
|
266
|
+
}
|
|
267
|
+
const x = model.x_time ?? [];
|
|
268
|
+
const tok = model.tokensUsage ?? [];
|
|
269
|
+
const calls = model.modelCallCount ?? [];
|
|
270
|
+
const nh = x.length;
|
|
271
|
+
const tu = model.totalUsage ?? {};
|
|
272
|
+
const total = tu.totalTokensUsage != null
|
|
273
|
+
? tu.totalTokensUsage
|
|
274
|
+
: tok.reduce((a, b) => a + (b || 0), 0);
|
|
275
|
+
const totalCalls = tu.totalModelCallCount != null
|
|
276
|
+
? tu.totalModelCallCount
|
|
277
|
+
: calls.reduce((a, b) => a + (b || 0), 0);
|
|
278
|
+
let peakIdx = -1;
|
|
279
|
+
let peakTok = 0;
|
|
280
|
+
for (let i = 0; i < tok.length; i++) {
|
|
281
|
+
const t = tok[i] || 0;
|
|
282
|
+
if (t > peakTok) {
|
|
283
|
+
peakTok = t;
|
|
284
|
+
peakIdx = i;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
const peakCalls = peakIdx >= 0 ? calls[peakIdx] || 0 : 0;
|
|
288
|
+
const pctPeak = total > 0 ? (peakTok / total) * 100 : 0;
|
|
289
|
+
const peakSlot = peakIdx >= 0 ? convertSlot(x[peakIdx] ?? '') : null;
|
|
290
|
+
const level = quota.level
|
|
291
|
+
? quota.level.charAt(0).toUpperCase() + quota.level.slice(1)
|
|
292
|
+
: '';
|
|
293
|
+
const left = ` GLM Coding Plan${level ? ' · ' + level : ''}`;
|
|
294
|
+
const firstSlot = convertSlot(x[0] ?? '');
|
|
295
|
+
const lastSlot = convertSlot(x[nh - 1] ?? '');
|
|
296
|
+
const win = firstSlot && lastSlot
|
|
297
|
+
? `${firstSlot.day} ${firstSlot.time} → ${lastSlot.day} ${lastSlot.time} · ${nh}h`
|
|
298
|
+
: platform;
|
|
299
|
+
out.push(line(plain(rule())));
|
|
300
|
+
out.push(line(bold(left), dim(pad(win, RULE_WIDTH - left.length, 'left'))));
|
|
301
|
+
out.push(line(plain(rule())));
|
|
302
|
+
const head = plain(` ${fmtTokens(total)} tokens across ${fmtNum(totalCalls)} model calls`);
|
|
303
|
+
out.push(line());
|
|
304
|
+
if (peakIdx >= 0 && peakSlot) {
|
|
305
|
+
out.push(line(head, plain(' — ')));
|
|
306
|
+
out.push(line(bold(`${Math.round(pctPeak)}%`), plain(' of it in a single hour')));
|
|
307
|
+
out.push(line(plain(` (${peakSlot.day} ${peakSlot.time}, ${fmtTokens(peakTok)} tokens / ${fmtNum(peakCalls)} calls).`)));
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
out.push(line(head, plain('.')));
|
|
311
|
+
}
|
|
312
|
+
const activeHours = tok.filter(t => t > 0).length;
|
|
313
|
+
const longest = idleRuns(tok)
|
|
314
|
+
.filter(r => r.len >= 2)
|
|
315
|
+
.sort((a, b) => b.len - a.len)
|
|
316
|
+
.at(0);
|
|
317
|
+
const tt = tool.totalUsage ?? {};
|
|
318
|
+
const searchN = tt.totalNetworkSearchCount || 0;
|
|
319
|
+
const readN = tt.totalWebReadMcpCount || 0;
|
|
320
|
+
const zreadN = tt.totalZreadMcpCount || 0;
|
|
321
|
+
const toolTotal = tt.totalSearchMcpCount != null
|
|
322
|
+
? tt.totalSearchMcpCount
|
|
323
|
+
: searchN + readN + zreadN;
|
|
324
|
+
let peakWinActive = 0;
|
|
325
|
+
let peakWinTokens = 0;
|
|
326
|
+
for (let k = 0; k < nh; k++) {
|
|
327
|
+
if (isPeakBucket(x[k]) && (tok[k] || 0) > 0) {
|
|
328
|
+
peakWinActive++;
|
|
329
|
+
peakWinTokens += tok[k] || 0;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
const peakWinPct = total > 0 ? (peakWinTokens / total) * 100 : 0;
|
|
333
|
+
const peakWin = localPeakWindow(apiOffsetMin, toOffset);
|
|
334
|
+
out.push(line());
|
|
335
|
+
out.push(line(plain(` Peak ${pad(peakSlot ? peakSlot.day + ' ' + peakSlot.time : '—', 15, 'right')}${pad(fmtTokens(peakTok), 7, 'left')} tokens · ${pad(fmtNum(peakCalls), 5, 'left')} calls`)));
|
|
336
|
+
out.push(line(plain(` Active ${pad(`${activeHours} / ${nh} hours`, 15, 'right')}${longest ? idleLabel(longest, x, convertSlot) : 'no idle gaps'}`)));
|
|
337
|
+
out.push(line(plain(` Tools ${pad(`${toolTotal} calls`, 15, 'right')}${searchN} searches · ${readN} reads${zreadN ? ` · ${zreadN} zread` : ''}`)));
|
|
338
|
+
if (peakWin) {
|
|
339
|
+
out.push(line(plain(` Peak hrs Mon–Fri ${peakWin.start}–${peakWin.end} · GLM-5.2 3× · ${peakWinActive}h active · ${fmtTokens(peakWinTokens)} (`), bold(`${Math.round(peakWinPct)}%`), plain(')')));
|
|
340
|
+
}
|
|
341
|
+
out.push(line());
|
|
342
|
+
const chartHdr = ' Hourly tokens · ↑ peak hour ';
|
|
343
|
+
out.push(line(dim(chartHdr + '─'.repeat(Math.max(0, RULE_WIDTH - chartHdr.length)))));
|
|
344
|
+
const maxTok = peakTok || 1;
|
|
345
|
+
const chartLines = hourlyVerticalChart({
|
|
346
|
+
x,
|
|
347
|
+
tok,
|
|
348
|
+
nh,
|
|
349
|
+
maxTok,
|
|
350
|
+
peakIdx,
|
|
351
|
+
slotFn: convertSlot,
|
|
352
|
+
});
|
|
353
|
+
if (chartLines) {
|
|
354
|
+
out.push(...chartLines.map((l) => line(plain(l))));
|
|
355
|
+
if (peakIdx >= 0 && peakSlot) {
|
|
356
|
+
out.push(line(plain(` ◂ peak ${peakSlot.day} ${peakSlot.time} ${fmtTokens(peakTok)} tokens · ${fmtNum(peakCalls)} calls`)));
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
else {
|
|
360
|
+
out.push(line(plain(' (not enough hourly data to chart)')));
|
|
361
|
+
}
|
|
362
|
+
out.push(line());
|
|
363
|
+
out.push(line(dim(' Model mix ' + '─'.repeat(Math.max(0, RULE_WIDTH - 11)))));
|
|
364
|
+
const mixSrc = model.modelSummaryList ?? model.modelDataList ?? [];
|
|
365
|
+
const denom = mixSrc.reduce((a, m) => a + (m.totalTokens ?? 0), 0) || 1;
|
|
366
|
+
const mixSorted = [...mixSrc].sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0));
|
|
367
|
+
for (const m of mixSorted) {
|
|
368
|
+
const pct = ((m.totalTokens ?? 0) / denom) * 100;
|
|
369
|
+
out.push(line(plain(` ${pad(m.modelName || '?', 11, 'right')}${pad(fmtTokens(m.totalTokens), 8, 'left')} `), bold(pad(pct.toFixed(1) + '%', 6, 'left')), plain(` ${bar(pct, 100, 20)}`)));
|
|
370
|
+
}
|
|
371
|
+
out.push(line());
|
|
372
|
+
out.push(line(dim(' Limits ' + '─'.repeat(Math.max(0, RULE_WIDTH - 8)))));
|
|
373
|
+
// The window does not bill on weekends, so the meter stays empty off Mon–Fri.
|
|
374
|
+
const bj = new Date(now.getTime() + apiOffsetMin * 60000);
|
|
375
|
+
const bjDay = bj.getUTCDay();
|
|
376
|
+
const inWindow = bjDay >= 1 && bjDay <= 5;
|
|
377
|
+
const elapsedMin = inWindow
|
|
378
|
+
? Math.max(0, Math.min(240, (bj.getUTCHours() - 14) * 60 + bj.getUTCMinutes()))
|
|
379
|
+
: 0;
|
|
380
|
+
if (peakWin) {
|
|
381
|
+
out.push(line(dim(` ${pad('Peak', 16, 'right')}`), plain(`${pad(peakWin.start, 5, 'left')} `), ...meter(Math.round((elapsedMin / 240) * 100), 22), plain(` ${peakWin.end}`)));
|
|
382
|
+
}
|
|
383
|
+
const limits = quota.limits ?? [];
|
|
384
|
+
const mcp = limits.find(l => /mcp/i.test(l.type ?? ''));
|
|
385
|
+
const tok5 = limits.find(l => /token/i.test(l.type ?? ''));
|
|
386
|
+
if (tok5) {
|
|
387
|
+
const reset = new Date(tok5.nextResetTime ?? '').toLocaleTimeString('en-IN');
|
|
388
|
+
out.push(line(dim(` ${pad('Tokens · 5h', 16, 'right')}`), bold(pad(`${tok5.percentage || 0}%`, 5, 'left')), plain(' '), ...meter(tok5.percentage, 22), plain(` ${reset}`)));
|
|
389
|
+
}
|
|
390
|
+
if (tok5 && mcp) {
|
|
391
|
+
out.push(line());
|
|
392
|
+
}
|
|
393
|
+
if (mcp) {
|
|
394
|
+
const reset = new Date(mcp.nextResetTime ?? '').toLocaleString('en-IN');
|
|
395
|
+
out.push(line(dim(` ${pad('MCP · this month', 16, 'right')}`), bold(pad(`${mcp.percentage || 0}%`, 5, 'left')), plain(' '), ...meter(mcp.percentage, 22), plain(` ${reset}`)));
|
|
396
|
+
const det = mcp.usageDetails ?? [];
|
|
397
|
+
const parts = det
|
|
398
|
+
.map(d => `${friendlyTool(d.modelCode)} ${fmtNum(d.usage)}`)
|
|
399
|
+
.join(' ');
|
|
400
|
+
if (parts) {
|
|
401
|
+
out.push(line(plain(` ${fmtNum(mcp.currentUsage)}M / ${fmtNum(mcp.totol)}M · ${parts}`)));
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
out.push(line());
|
|
405
|
+
out.push(line(plain(rule())));
|
|
406
|
+
return out;
|
|
407
|
+
}
|
|
408
|
+
export function render(input) {
|
|
409
|
+
return textOf(doc(...reportLines(input)));
|
|
410
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { runCli } from '@v1nvn/agentic-core';
|
|
3
|
+
import { render, reportLines } from "./format.js";
|
|
4
|
+
import { cliSpec, resolveConfig } from "./resolve.js";
|
|
5
|
+
import { fetchReport } from "./usage.js";
|
|
6
|
+
process.exitCode = await runCli(cliSpec(async (options) => {
|
|
7
|
+
const input = await fetchReport(resolveConfig(process.env, {
|
|
8
|
+
authToken: options.authToken || undefined,
|
|
9
|
+
baseUrl: options.baseUrl || undefined,
|
|
10
|
+
command: 'usage',
|
|
11
|
+
json: options.json === true,
|
|
12
|
+
}));
|
|
13
|
+
console.log(options.json === true
|
|
14
|
+
? JSON.stringify({ lines: reportLines(input) })
|
|
15
|
+
: render(input));
|
|
16
|
+
}), process.argv.slice(2));
|
package/src/index.ts
CHANGED
|
File without changes
|
package/src/resolve.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { buildCli, HelpRequested, parseCli, } from '@v1nvn/agentic-core';
|
|
2
|
+
const DEFAULT_BASE_URL = 'https://api.z.ai';
|
|
3
|
+
const GLM_HOSTS = new Set(['api.z.ai', 'dev.bigmodel.cn', 'open.bigmodel.cn']);
|
|
4
|
+
function fromEnv(value) {
|
|
5
|
+
return value ? value : undefined;
|
|
6
|
+
}
|
|
7
|
+
function originOf(value) {
|
|
8
|
+
try {
|
|
9
|
+
const url = new URL(value);
|
|
10
|
+
return url.protocol === 'https:' || url.protocol === 'http:'
|
|
11
|
+
? url.origin
|
|
12
|
+
: undefined;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function isGlm(origin) {
|
|
19
|
+
return GLM_HOSTS.has(new URL(origin).hostname);
|
|
20
|
+
}
|
|
21
|
+
function resolveBaseUrl(flag, env) {
|
|
22
|
+
if (flag !== undefined) {
|
|
23
|
+
const origin = originOf(flag);
|
|
24
|
+
if (origin === undefined) {
|
|
25
|
+
throw new Error(`invalid base URL: ${flag}`);
|
|
26
|
+
}
|
|
27
|
+
return { glm: isGlm(origin), origin };
|
|
28
|
+
}
|
|
29
|
+
const inherited = fromEnv(env.ANTHROPIC_BASE_URL);
|
|
30
|
+
const origin = inherited === undefined ? undefined : originOf(inherited);
|
|
31
|
+
if (origin !== undefined && isGlm(origin)) {
|
|
32
|
+
return { glm: true, origin };
|
|
33
|
+
}
|
|
34
|
+
return { glm: false, origin: DEFAULT_BASE_URL };
|
|
35
|
+
}
|
|
36
|
+
export function cliSpec(run) {
|
|
37
|
+
return {
|
|
38
|
+
name: 'zai-usage',
|
|
39
|
+
description: 'GLM Coding Plan usage — one command, usage',
|
|
40
|
+
commands: [
|
|
41
|
+
{
|
|
42
|
+
name: 'usage',
|
|
43
|
+
description: 'GLM Coding Plan quota and usage report',
|
|
44
|
+
options: [
|
|
45
|
+
{
|
|
46
|
+
flags: '--auth-token <token>',
|
|
47
|
+
description: 'API key',
|
|
48
|
+
env: 'ZAI_AUTH_TOKEN',
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
flags: '--base-url <url>',
|
|
52
|
+
description: 'base URL',
|
|
53
|
+
env: 'ZAI_BASE_URL',
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
flags: '--json',
|
|
57
|
+
description: 'print the report lines as JSON for the zai mod',
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
run,
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
export function buildProgram() {
|
|
66
|
+
return buildCli(cliSpec());
|
|
67
|
+
}
|
|
68
|
+
export function parseArgs(args) {
|
|
69
|
+
const parsed = parseCli(cliSpec(), args);
|
|
70
|
+
if (parsed === undefined || parsed instanceof HelpRequested) {
|
|
71
|
+
return parsed;
|
|
72
|
+
}
|
|
73
|
+
if (parsed.command !== 'usage') {
|
|
74
|
+
return {
|
|
75
|
+
authToken: undefined,
|
|
76
|
+
baseUrl: undefined,
|
|
77
|
+
command: undefined,
|
|
78
|
+
json: false,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
const { authToken, baseUrl, json } = parsed.options;
|
|
82
|
+
return {
|
|
83
|
+
authToken: authToken || undefined,
|
|
84
|
+
baseUrl: baseUrl || undefined,
|
|
85
|
+
command: 'usage',
|
|
86
|
+
json: json === true,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
export function resolveConfig(env, parsed) {
|
|
90
|
+
const base = resolveBaseUrl(parsed.baseUrl, env);
|
|
91
|
+
const token = parsed.authToken ??
|
|
92
|
+
(base.glm ? fromEnv(env.ANTHROPIC_AUTH_TOKEN) : undefined);
|
|
93
|
+
if (token === undefined) {
|
|
94
|
+
throw new Error(env.ANTHROPIC_AUTH_TOKEN
|
|
95
|
+
? 'ANTHROPIC_AUTH_TOKEN is set, but the resolved base URL does not name a GLM host, so it is not a GLM Coding Plan token — set ZAI_AUTH_TOKEN (or --auth-token)'
|
|
96
|
+
: 'no API key: set ZAI_AUTH_TOKEN (or --auth-token)');
|
|
97
|
+
}
|
|
98
|
+
return { token, url: base.origin };
|
|
99
|
+
}
|
package/src/usage.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usage query: the three GLM monitor endpoints of the resolved base URL,
|
|
3
|
+
* assembled into the report input.
|
|
4
|
+
*/
|
|
5
|
+
import { pad2 } from '@v1nvn/agentic-core/draw';
|
|
6
|
+
// The monitor API labels every bucket in Beijing time; the query window runs
|
|
7
|
+
// from yesterday at the current hour to the end of today's current hour.
|
|
8
|
+
function queryParams(now = new Date()) {
|
|
9
|
+
const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, now.getHours(), 0, 0, 0);
|
|
10
|
+
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), 59, 59, 999);
|
|
11
|
+
function formatDateTime(date) {
|
|
12
|
+
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`;
|
|
13
|
+
}
|
|
14
|
+
return `?startTime=${encodeURIComponent(formatDateTime(startDate))}&endTime=${encodeURIComponent(formatDateTime(endDate))}`;
|
|
15
|
+
}
|
|
16
|
+
// Rename the API's limit kinds to their display names; `totol` is the field the
|
|
17
|
+
// renderer reads (sic — the upstream spelling).
|
|
18
|
+
function processQuotaLimit(data) {
|
|
19
|
+
if (!data.limits) {
|
|
20
|
+
return data;
|
|
21
|
+
}
|
|
22
|
+
data.limits = data.limits.map(item => {
|
|
23
|
+
if (item.type === 'TOKENS_LIMIT') {
|
|
24
|
+
return {
|
|
25
|
+
type: 'Token usage(5 Hour)',
|
|
26
|
+
percentage: item.percentage,
|
|
27
|
+
nextResetTime: item.nextResetTime,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
if (item.type === 'TIME_LIMIT') {
|
|
31
|
+
return {
|
|
32
|
+
type: 'MCP usage(1 Month)',
|
|
33
|
+
percentage: item.percentage,
|
|
34
|
+
currentUsage: item.currentValue,
|
|
35
|
+
totol: item.usage,
|
|
36
|
+
usageDetails: item.usageDetails,
|
|
37
|
+
nextResetTime: item.nextResetTime,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
return item;
|
|
41
|
+
});
|
|
42
|
+
return data;
|
|
43
|
+
}
|
|
44
|
+
async function fetchJson(apiUrl, label, authToken, query = '') {
|
|
45
|
+
const res = await fetch(apiUrl + query, {
|
|
46
|
+
headers: {
|
|
47
|
+
Authorization: authToken,
|
|
48
|
+
'Accept-Language': 'en-US,en',
|
|
49
|
+
'Content-Type': 'application/json',
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
const body = await res.text();
|
|
53
|
+
if (res.status !== 200) {
|
|
54
|
+
throw new Error(`[${label}] HTTP ${res.status}\n${body}`);
|
|
55
|
+
}
|
|
56
|
+
let json;
|
|
57
|
+
try {
|
|
58
|
+
json = JSON.parse(body);
|
|
59
|
+
}
|
|
60
|
+
catch (e) {
|
|
61
|
+
throw new Error(`[${label}] could not parse response: ${e.message}`, { cause: e });
|
|
62
|
+
}
|
|
63
|
+
if (json.success === false) {
|
|
64
|
+
throw new Error(`[${label}] ${json.msg ?? 'request failed'} (code ${json.code ?? '?'})`);
|
|
65
|
+
}
|
|
66
|
+
return json.data ?? json;
|
|
67
|
+
}
|
|
68
|
+
export async function fetchReport(config) {
|
|
69
|
+
const platform = config.url.includes('bigmodel') ? 'ZHIPU' : 'ZAI';
|
|
70
|
+
const query = queryParams();
|
|
71
|
+
try {
|
|
72
|
+
const [model, tool, quotaRaw] = await Promise.all([
|
|
73
|
+
fetchJson(`${config.url}/api/monitor/usage/model-usage`, 'Model usage', config.token, query),
|
|
74
|
+
fetchJson(`${config.url}/api/monitor/usage/tool-usage`, 'Tool usage', config.token, query),
|
|
75
|
+
fetchJson(`${config.url}/api/monitor/usage/quota/limit`, 'Quota limit', config.token),
|
|
76
|
+
]);
|
|
77
|
+
return {
|
|
78
|
+
platform,
|
|
79
|
+
model: model,
|
|
80
|
+
tool: tool,
|
|
81
|
+
quota: processQuotaLimit(quotaRaw),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
catch (e) {
|
|
85
|
+
throw new Error(`Request failed: ${e.message}`, { cause: e });
|
|
86
|
+
}
|
|
87
|
+
}
|