opencode-glm-quota 1.1.0 → 1.2.0

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.
@@ -2,7 +2,7 @@
2
2
  * HTTP client module
3
3
  * Makes HTTPS requests to Z.ai API endpoints
4
4
  */
5
- import * as https from 'https';
5
+ import * as https from 'node:https';
6
6
  /**
7
7
  * Make HTTPS request to API endpoint
8
8
  * @param options - Request options
package/dist/index.js CHANGED
@@ -13,6 +13,7 @@ import { getEndpoints } from "./api/endpoints.js";
13
13
  import { queryEndpoint } from "./api/client.js";
14
14
  import { getTimeWindow, getTimeWindowQueryParams } from "./utils/time-window.js";
15
15
  import { formatProgressLine } from "./utils/progress-bar.js";
16
+ import { formatTimeUntilReset } from "./utils/reset-timer.js";
16
17
  // ============================================================================
17
18
  // CONSTANTS
18
19
  // ============================================================================
@@ -128,7 +129,8 @@ function processQuotaLimit(data) {
128
129
  if (limit.type === 'TOKENS_LIMIT') {
129
130
  return {
130
131
  type: 'Token usage(5 Hour)',
131
- percentage: typeof limit.percentage === 'number' ? limit.percentage : 0
132
+ percentage: typeof limit.percentage === 'number' ? limit.percentage : 0,
133
+ nextResetTime: limit.nextResetTime
132
134
  };
133
135
  }
134
136
  if (limit.type === 'TIME_LIMIT') {
@@ -156,38 +158,55 @@ function formatNumber(num) {
156
158
  return num.toLocaleString('en-US');
157
159
  }
158
160
  /**
159
- * Format model usage data as readable lines
161
+ * Get token limit information from quota data
160
162
  */
161
- function formatModelUsage(data, quotaData) {
162
- const lines = [];
163
- const totalUsage = data.totalUsage;
164
- // Find token limit info from quota
163
+ function getTokenLimitInfo(quotaData) {
165
164
  let tokenLimit = 40000000; // Default 40M
166
165
  let tokenPct = 0;
167
- if (quotaData?.limits) {
168
- for (const limit of quotaData.limits) {
169
- if (limit.type === 'Token usage(5 Hour)') {
170
- tokenPct = typeof limit.percentage === 'number' ? limit.percentage : 0;
171
- tokenLimit = limit.total || 40000000;
172
- break;
173
- }
166
+ if (!quotaData?.limits)
167
+ return { tokenLimit, tokenPct };
168
+ for (const limit of quotaData.limits) {
169
+ if (limit.type === 'Token usage(5 Hour)') {
170
+ tokenPct = typeof limit.percentage === 'number' ? limit.percentage : 0;
171
+ tokenLimit = limit.total || 40000000;
172
+ break;
174
173
  }
175
174
  }
176
- if (totalUsage) {
177
- const calls = totalUsage.totalModelCallCount;
178
- const tokens = totalUsage.totalTokensUsage;
179
- if (tokens !== undefined) {
180
- // Show 24h tokens and percentage relative to 5h limit
181
- const pct24h = Math.round((tokens / tokenLimit) * 100);
182
- lines.push(` Total Tokens (24h): ${formatNumber(tokens)} (${pct24h}% of 5h limit)`);
183
- lines.push(` 5h Window Usage: ${tokenPct}% of ${formatNumber(tokenLimit)}`);
184
- }
185
- if (calls !== undefined) {
186
- lines.push(` Total Calls: ${formatNumber(calls)}`);
187
- }
175
+ return { tokenLimit, tokenPct };
176
+ }
177
+ /**
178
+ * Format MCP tool details as readable lines
179
+ */
180
+ function formatMcpToolLines(details) {
181
+ const lines = [];
182
+ const mcpTotal = details.reduce((sum, d) => sum + (d.usage || 0), 0);
183
+ for (const d of details) {
184
+ const pct = mcpTotal > 0 ? Math.round((d.usage / mcpTotal) * 100) : 0;
185
+ lines.push(` - ${d.modelCode}: ${d.usage} (${pct}%)`);
188
186
  }
189
- else {
187
+ return lines;
188
+ }
189
+ /**
190
+ * Format model usage data as readable lines
191
+ */
192
+ function formatModelUsage(data, quotaData) {
193
+ const lines = [];
194
+ const totalUsage = data.totalUsage;
195
+ // Get token limit info from quota
196
+ const { tokenLimit, tokenPct } = getTokenLimitInfo(quotaData);
197
+ if (!totalUsage) {
190
198
  lines.push(' No usage data');
199
+ return lines;
200
+ }
201
+ const calls = totalUsage.totalModelCallCount;
202
+ const tokens = totalUsage.totalTokensUsage;
203
+ if (tokens !== undefined) {
204
+ // Show 24h tokens and percentage relative to 5h limit
205
+ const pct24h = Math.round((tokens / tokenLimit) * 100);
206
+ lines.push(` Total Tokens (24h): ${formatNumber(tokens)} (${pct24h}% of 5h limit)`, ` 5h Window Usage: ${tokenPct}% of ${formatNumber(tokenLimit)}`);
207
+ }
208
+ if (calls !== undefined) {
209
+ lines.push(` Total Calls: ${formatNumber(calls)}`);
191
210
  }
192
211
  return lines;
193
212
  }
@@ -202,24 +221,15 @@ function formatToolUsage(data, quotaData) {
202
221
  const search = totalUsage.totalNetworkSearchCount;
203
222
  const webRead = totalUsage.totalWebReadMcpCount;
204
223
  const zread = totalUsage.totalZreadMcpCount;
205
- if (search !== undefined)
206
- lines.push(` Network Searches: ${formatNumber(search)}`);
207
- if (webRead !== undefined)
208
- lines.push(` Web Reads: ${formatNumber(webRead)}`);
209
- if (zread !== undefined)
210
- lines.push(` ZRead Calls: ${formatNumber(zread)}`);
224
+ lines.push(...(search !== undefined ? [` Network Searches: ${formatNumber(search)}`] : []), ...(webRead !== undefined ? [` Web Reads: ${formatNumber(webRead)}`] : []), ...(zread !== undefined ? [` ZRead Calls: ${formatNumber(zread)}`] : []));
211
225
  }
212
226
  // Show MCP usage details from quota if available
213
227
  if (quotaData?.limits) {
214
228
  for (const limit of quotaData.limits) {
215
229
  if (limit.type === 'MCP usage(1 Month)' && limit.usageDetails) {
216
- const details = limit.usageDetails;
217
230
  lines.push(' MCP Tool Details:');
218
- const mcpTotal = details.reduce((sum, d) => sum + (d.usage || 0), 0);
219
- for (const d of details) {
220
- const pct = mcpTotal > 0 ? Math.round((d.usage / mcpTotal) * 100) : 0;
221
- lines.push(` - ${d.modelCode}: ${d.usage} (${pct}%)`);
222
- }
231
+ const details = limit.usageDetails;
232
+ lines.push(...formatMcpToolLines(details));
223
233
  break;
224
234
  }
225
235
  }
@@ -229,46 +239,65 @@ function formatToolUsage(data, quotaData) {
229
239
  }
230
240
  return lines;
231
241
  }
242
+ // ============================================================================
243
+ // HELPER FUNCTIONS FOR OUTPUT FORMATTING
244
+ // ============================================================================
232
245
  /**
233
- * Format usage statistics as ASCII table
234
- * @param platform - Platform name
246
+ * Format a single line with box characters
247
+ * @param content - Content to display (without padding)
248
+ * @param lineIndent - Total line width after padding
249
+ * @returns Formatted line with box characters
250
+ */
251
+ function formatBoxLine(content, lineIndent) {
252
+ return '║ ' + content.padEnd(lineIndent) + '║';
253
+ }
254
+ /**
255
+ * Format header section
256
+ * @param platformName - Platform name
235
257
  * @param startTime - Start time string
236
258
  * @param endTime - End time string
237
- * @param quotaData - Quota limit data
238
- * @param modelData - Model usage data
239
- * @param toolData - Tool usage data
240
- * @returns Formatted output string
259
+ * @returns Array of header lines
241
260
  */
242
- function formatOutput(platform, startTime, endTime, quotaData, modelData, toolData) {
261
+ function formatHeader(platformName, startTime, endTime) {
243
262
  const lines = [];
244
- const platformName = getPlatformName(platform);
245
- // Constants for line width (total 60 chars)
246
263
  const LINE_WIDTH = 60;
247
- const LINE_CONTENT = 58; // Between ║ and ║
248
- const LINE_INDENT = 56; // After "║ "
249
- // Header
250
264
  lines.push('╔' + '═'.repeat(58) + '╗');
251
265
  lines.push('║' + ' '.repeat(58) + '║');
252
266
  lines.push('║' + ' Z.ai GLM Coding Plan Usage Statistics '.padStart(35).padEnd(58) + '║');
253
267
  lines.push('║' + ' '.repeat(58) + '║');
254
268
  lines.push('╠' + '═'.repeat(58) + '╣');
255
- // Platform line: "║ Platform: " (13 chars) + name + padding + "║"
256
269
  lines.push('║ Platform: ' + platformName.padEnd(LINE_WIDTH - 13 - 1) + '║');
257
- // Period line: "║ Period: " (14 chars) + start + " → " + end + "║"
258
270
  const periodLine = '║ Period: ' + startTime + ' → ' + endTime;
259
271
  lines.push(periodLine.padEnd(LINE_WIDTH) + '║');
260
272
  lines.push('╠' + '═'.repeat(58) + '╣');
261
- // Quota Limits
273
+ return lines;
274
+ }
275
+ /**
276
+ * Format quota limits section
277
+ * @param quotaData - Quota limit data
278
+ * @returns Array of quota lines
279
+ */
280
+ function formatQuotaLimits(quotaData) {
281
+ const lines = [];
282
+ const LINE_CONTENT = 58;
283
+ const LINE_INDENT = 56;
262
284
  lines.push('║ 📊 QUOTA LIMITS' + ' '.repeat(LINE_CONTENT - 14) + '║');
263
285
  lines.push('╟' + '─'.repeat(58) + '╢');
264
- if (quotaData?.limits && Array.isArray(quotaData.limits)) {
265
- for (const limit of quotaData.limits) {
286
+ const limits = quotaData?.limits;
287
+ if (limits && Array.isArray(limits)) {
288
+ for (const limit of limits) {
266
289
  const pct = typeof limit.percentage === 'number' ? limit.percentage : 0;
267
290
  const line = formatProgressLine(limit.type || 'Unknown', pct);
268
- lines.push('║ ' + line.padEnd(LINE_INDENT) + '║');
291
+ lines.push(formatBoxLine(line, LINE_INDENT));
292
+ if (limit.nextResetTime !== undefined) {
293
+ const resetMsg = formatTimeUntilReset(limit.nextResetTime);
294
+ if (resetMsg) {
295
+ lines.push(formatBoxLine(resetMsg, LINE_INDENT));
296
+ }
297
+ }
269
298
  if (limit.currentValue !== undefined && limit.total !== undefined) {
270
299
  const usageStr = ' Used: ' + limit.currentValue + '/' + limit.total;
271
- lines.push('║ ' + usageStr.padEnd(LINE_INDENT) + '║');
300
+ lines.push(formatBoxLine(usageStr, LINE_INDENT));
272
301
  }
273
302
  }
274
303
  }
@@ -276,33 +305,60 @@ function formatOutput(platform, startTime, endTime, quotaData, modelData, toolDa
276
305
  lines.push('║ No quota data available' + ' '.repeat(LINE_INDENT - 21) + '║');
277
306
  }
278
307
  lines.push('╠' + '═'.repeat(58) + '╣');
279
- // Model Usage
280
- lines.push('║ 🤖 MODEL USAGE (24h)' + ' '.repeat(LINE_INDENT - 17) + '║');
308
+ return lines;
309
+ }
310
+ /**
311
+ * Format data section with optional data
312
+ * @param title - Section title
313
+ * @param data - Data object or null
314
+ * @param formatter - Function to format data if present
315
+ * @param noDataMessage - Message to show if no data
316
+ * @param LINE_INDENT - Line indent width
317
+ * @returns Array of section lines
318
+ */
319
+ function formatDataSection(title, data, formatter, quotaData, noDataMessage, LINE_INDENT) {
320
+ const lines = [];
321
+ const LINE_CONTENT = 58;
322
+ lines.push('║ ' + title + ' '.repeat(LINE_CONTENT - title.length) + '║');
281
323
  lines.push('╟' + '─'.repeat(58) + '╢');
282
- if (modelData) {
283
- const modelLines = formatModelUsage(modelData, quotaData);
284
- for (const line of modelLines) {
285
- lines.push('║ ' + line.padEnd(LINE_INDENT) + '║');
324
+ if (data) {
325
+ const formattedLines = formatter(data, quotaData);
326
+ for (const line of formattedLines) {
327
+ lines.push(formatBoxLine(line, LINE_INDENT));
286
328
  }
287
329
  }
288
330
  else {
289
- lines.push('║ No model usage data available' + ' '.repeat(LINE_INDENT - 25) + '║');
331
+ lines.push('║ ' + noDataMessage + ' '.repeat(LINE_INDENT - noDataMessage.length) + '║');
290
332
  }
291
333
  lines.push('╠' + '═'.repeat(58) + '╣');
292
- // Tool Usage
293
- lines.push('║ 🔧 TOOL/MCP USAGE (24h)' + ' '.repeat(LINE_INDENT - 20) + '║');
294
- lines.push('╟' + '─'.repeat(58) + '╢');
295
- if (toolData) {
296
- const toolLines = formatToolUsage(toolData, quotaData);
297
- for (const line of toolLines) {
298
- lines.push('║ ' + line.padEnd(LINE_INDENT) + '║');
299
- }
300
- }
301
- else {
302
- lines.push('║ No tool usage data available' + ' '.repeat(LINE_INDENT - 24) + '║');
303
- }
304
- // Footer
305
- lines.push('╚' + '═'.repeat(58) + '╝');
334
+ return lines;
335
+ }
336
+ /**
337
+ * Format footer section
338
+ * @returns Footer lines
339
+ */
340
+ function formatFooter() {
341
+ return ['╚' + '═'.repeat(58) + '╝'];
342
+ }
343
+ /**
344
+ * Format usage statistics as ASCII table
345
+ * @param platform - Platform name
346
+ * @param startTime - Start time string
347
+ * @param endTime - End time string
348
+ * @param quotaData - Quota limit data
349
+ * @param modelData - Model usage data
350
+ * @param toolData - Tool usage data
351
+ * @returns Formatted output string
352
+ */
353
+ function formatOutput(platform, startTime, endTime, quotaData, modelData, toolData) {
354
+ const lines = [];
355
+ const platformName = getPlatformName(platform);
356
+ const LINE_INDENT = 56;
357
+ lines.push(...formatHeader(platformName, startTime, endTime));
358
+ lines.push(...formatQuotaLimits(quotaData));
359
+ lines.push(...formatDataSection('🤖 MODEL USAGE (24h)', modelData, formatModelUsage, quotaData, 'No model usage data available', LINE_INDENT));
360
+ lines.push(...formatDataSection('🔧 TOOL/MCP USAGE (24h)', toolData, formatToolUsage, quotaData, 'No tool usage data available', LINE_INDENT));
361
+ lines.push(...formatFooter());
306
362
  return lines.join('\n');
307
363
  }
308
364
  // ============================================================================
@@ -22,10 +22,11 @@ export function formatDateTime(date) {
22
22
  * @returns Date object
23
23
  */
24
24
  export function parseDateTime(dateTime) {
25
- const match = dateTime.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/);
25
+ const dateTimeRegex = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/;
26
+ const match = dateTimeRegex.exec(dateTime);
26
27
  if (!match) {
27
28
  throw new Error(`Invalid datetime format: ${dateTime}`);
28
29
  }
29
30
  const [, year, month, day, hours, minutes, seconds] = match;
30
- return new Date(parseInt(year), parseInt(month) - 1, parseInt(day), parseInt(hours), parseInt(minutes), parseInt(seconds));
31
+ return new Date(Number.parseInt(year), Number.parseInt(month) - 1, Number.parseInt(day), Number.parseInt(hours), Number.parseInt(minutes), Number.parseInt(seconds));
31
32
  }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Reset timer utility module
3
+ * Formats Unix timestamps to human-readable countdown strings
4
+ */
5
+ /**
6
+ * Format time until reset as "X hours Y minutes"
7
+ * @param resetTime - Unix timestamp in milliseconds (from API's nextResetTime field)
8
+ * @returns Human-readable countdown string or empty string if invalid/past
9
+ */
10
+ export function formatTimeUntilReset(resetTime) {
11
+ // Validate input
12
+ if (resetTime === null || resetTime === undefined) {
13
+ return '';
14
+ }
15
+ // Check if valid number
16
+ if (typeof resetTime !== 'number' || Number.isNaN(resetTime) || resetTime < 0) {
17
+ return '';
18
+ }
19
+ // Calculate time difference
20
+ const now = Date.now();
21
+ const diffMs = resetTime - now;
22
+ // Return empty for past timestamps
23
+ if (diffMs <= 0) {
24
+ return '';
25
+ }
26
+ // Convert to hours and minutes
27
+ const totalMinutes = Math.floor(diffMs / (1000 * 60));
28
+ const hours = Math.floor(totalMinutes / 60);
29
+ const minutes = totalMinutes % 60;
30
+ return `Resets in ${hours} hours ${minutes} minutes`;
31
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-glm-quota",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "description": "OpenCode plugin to query Z.ai GLM Coding Plan usage statistics including quota limits, model usage, and MCP tool usage",
6
6
  "main": "dist/index.js",
@@ -14,7 +14,8 @@
14
14
  "build": "tsc",
15
15
  "clean": "rm -rf dist",
16
16
  "prepublishOnly": "npm run clean && npm run build",
17
- "test": "tsx --test",
17
+ "test": "tsx --test $(find tests -name '*.test.ts' -type f)",
18
+ "test:coverage": "c8 --reporter=lcov --reporter=text -- npm test",
18
19
  "lint": "eslint src/"
19
20
  },
20
21
  "keywords": [
@@ -49,10 +50,12 @@
49
50
  "devDependencies": {
50
51
  "@opencode-ai/plugin": "latest",
51
52
  "@types/node": "^20.0.0",
52
- "tsx": "^4.0.0",
53
- "typescript": "^5.0.0",
54
- "eslint": "^8.0.0",
55
53
  "@typescript-eslint/eslint-plugin": "^6.0.0",
56
- "@typescript-eslint/parser": "^6.0.0"
54
+ "@typescript-eslint/parser": "^6.0.0",
55
+ "baseline-browser-mapping": "^2.9.17",
56
+ "c8": "^10.1.3",
57
+ "eslint": "^8.0.0",
58
+ "tsx": "^4.0.0",
59
+ "typescript": "^5.0.0"
57
60
  }
58
61
  }