glm-quota-line 0.4.0 → 0.6.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.0
4
+
5
+ - Added official international GLM quota endpoint support for `api.z.ai` while preserving domestic `open.bigmodel.cn` detection
6
+ - Added dual-token status rendering for the new package shape, showing both the 5h quota and weekly quota in `text`, `compact`, and `bar` styles
7
+ - Kept old packages compatible by continuing to read the legacy `TOKENS_LIMIT(number=5)` quota and ignoring `TIME_LIMIT` / MCP usage
8
+ - Updated cache compatibility so existing cached percent-based results still render after upgrading
9
+ - Refreshed English and Chinese README examples to document international setup and the new token-only display behavior
10
+
11
+ ## 0.5.0
12
+
13
+ - Changed bar characters from `■□` (discrete squares) to `█░` (continuous blocks) to match Claude Code official statusline style
14
+
3
15
  ## 0.4.0
4
16
 
5
17
  - Added managed Claude Code `SessionStart` hooks to pre-refresh quota on `startup`, `resume`, `clear`, and `compact`
package/README.md CHANGED
@@ -73,18 +73,20 @@ glm-quota-line --style compact
73
73
  ## Output
74
74
 
75
75
  ```text
76
- GLM Lite | 5h left 91% | reset 14:47
77
- GLM 91% | 14:47
78
- GLM Lite ■□□□□□□□□□ 91% | 14:47
76
+ GLM Lite | 5h left 91% | week 47% | reset 14:47
77
+
78
+ GLM 5h 91% W 47% | 14:47
79
+
80
+ GLM Lite █░░░░░░░░░ 91% | W 47% | 14:47
79
81
  ```
80
82
 
81
83
  ## Recommended Setups
82
84
 
83
- | Use case | Config |
84
- | --- | --- |
85
- | Dark terminal | `style=bar`, `theme=ansi`, `palette=dark` |
85
+ | Use case | Config |
86
+ | -------------- | --------------------------------------------- |
87
+ | Dark terminal | `style=bar`, `theme=ansi`, `palette=dark` |
86
88
  | Light terminal | `style=compact`, `theme=ansi`, `palette=mono` |
87
- | Plain fallback | any `style`, `theme=plain` |
89
+ | Plain fallback | any `style`, `theme=plain` |
88
90
 
89
91
  ## Color Presets
90
92
 
@@ -117,6 +119,8 @@ If Claude Code is running behind a gateway or proxy and the injected `ANTHROPIC_
117
119
  ```bash
118
120
  glm-quota-line config set auth-token <your-real-token>
119
121
  glm-quota-line config set base-url https://open.bigmodel.cn/api/anthropic
122
+ # or
123
+ glm-quota-line config set base-url https://api.z.ai/api/anthropic
120
124
  ```
121
125
 
122
126
  Clear them later with:
@@ -153,6 +157,7 @@ Run `glm-quota-line --help` for the full command descriptions and examples.
153
157
  - Default output is plain text
154
158
  - Missing or expired auth returns `GLM | auth expired`
155
159
  - API or parsing failures return `GLM | quota unavailable`
160
+ - Only `TOKENS_LIMIT` quotas are shown; `TIME_LIMIT` / MCP usage is ignored
156
161
  - `install` wires both the status line command and managed `SessionStart` hooks
157
162
  - `install` does not replace an unmanaged Claude status line unless `--force` is used
158
163
  - `install --force` backs up the previous entry and `uninstall` restores it when possible
package/README.zh-CN.md CHANGED
@@ -73,18 +73,20 @@ glm-quota-line --style compact
73
73
  ## 输出示例
74
74
 
75
75
  ```text
76
- GLM Lite | 5h left 91% | reset 14:47
77
- GLM 91% | 14:47
78
- GLM Lite ■□□□□□□□□□ 91% | 14:47
76
+ GLM Lite | 5h 91% | week 47% | reset 14:47
77
+
78
+ GLM 5h 91% W 47% | 14:47
79
+
80
+ GLM Lite █░░░░░░░░░ 91% | W 47% | 14:47
79
81
  ```
80
82
 
81
83
  ## 推荐搭配
82
84
 
83
- | 使用场景 | 配置 |
84
- | --- | --- |
85
- | 深色终端 | `style=bar`, `theme=ansi`, `palette=dark` |
86
- | 浅色终端 | `style=compact`, `theme=ansi`, `palette=mono` |
87
- | 纯文本回退 | 任意 `style`,`theme=plain` |
85
+ | 使用场景 | 配置 |
86
+ | ---------- | --------------------------------------------- |
87
+ | 深色终端 | `style=bar`, `theme=ansi`, `palette=dark` |
88
+ | 浅色终端 | `style=compact`, `theme=ansi`, `palette=mono` |
89
+ | 纯文本回退 | 任意 `style`,`theme=plain` |
88
90
 
89
91
  ## 颜色搭配
90
92
 
@@ -117,6 +119,8 @@ glm-quota-line config set theme plain
117
119
  ```bash
118
120
  glm-quota-line config set auth-token <your-real-token>
119
121
  glm-quota-line config set base-url https://open.bigmodel.cn/api/anthropic
122
+ # 或
123
+ glm-quota-line config set base-url https://api.z.ai/api/anthropic
120
124
  ```
121
125
 
122
126
  清除覆盖值:
@@ -157,6 +161,7 @@ glm-quota-line --help
157
161
  - 默认输出为纯文本
158
162
  - 鉴权缺失或失效时返回 `GLM | auth expired`
159
163
  - 接口异常或解析失败时返回 `GLM | quota unavailable`
164
+ - 只展示 `TOKENS_LIMIT` 配额,忽略 `TIME_LIMIT` / MCP 使用量
160
165
  - `install` 会同时接入状态栏命令和受工具管理的 `SessionStart` hooks
161
166
  - `install` 默认不会覆盖非本工具管理的 Claude 状态栏,除非显式使用 `--force`
162
167
  - `install --force` 会备份旧配置,`uninstall` 会在可能时恢复
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glm-quota-line",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Claude Code status line helper for GLM Coding Plan quota.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,24 +1,72 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
 
4
- function isSuccessCacheShape(value) {
4
+ function isValidQuotaShape(value) {
5
+ if (!value || typeof value.key !== "string") {
6
+ return false;
7
+ }
8
+
9
+ if (!Number.isFinite(value.leftPercent)) {
10
+ return false;
11
+ }
12
+
13
+ if (!Number.isFinite(value.usedPercent)) {
14
+ return false;
15
+ }
16
+
17
+ if ("nextResetTime" in value && !Number.isFinite(value.nextResetTime)) {
18
+ return false;
19
+ }
20
+
21
+ return true;
22
+ }
23
+
24
+ function isLegacyPercentSuccessCacheShape(value) {
25
+ if (!value || value.kind !== "success" || typeof value.level !== "string") {
26
+ return false;
27
+ }
28
+
29
+ if (value.display !== "percent") {
30
+ return false;
31
+ }
32
+
33
+ if (!Number.isFinite(value.leftPercent)) {
34
+ return false;
35
+ }
36
+
37
+ if ("usedPercent" in value && !Number.isFinite(value.usedPercent)) {
38
+ return false;
39
+ }
40
+
41
+ if ("nextResetTime" in value && !Number.isFinite(value.nextResetTime)) {
42
+ return false;
43
+ }
44
+
45
+ return true;
46
+ }
47
+
48
+ function isMultiQuotaSuccessCacheShape(value) {
5
49
  if (!value || value.kind !== "success" || typeof value.level !== "string") {
6
50
  return false;
7
51
  }
8
52
 
9
- if (!Number.isFinite(value.nextResetTime)) {
53
+ if (!Array.isArray(value.quotas) || value.quotas.length === 0) {
10
54
  return false;
11
55
  }
12
56
 
13
- if (value.display === "percent") {
14
- return Number.isFinite(value.leftPercent);
57
+ if (!value.quotas.every(isValidQuotaShape)) {
58
+ return false;
15
59
  }
16
60
 
17
- if (value.display === "absolute") {
18
- return Number.isFinite(value.remaining) && Number.isFinite(value.total);
61
+ if ("primaryQuotaKey" in value && typeof value.primaryQuotaKey !== "string") {
62
+ return false;
19
63
  }
20
64
 
21
- return false;
65
+ return true;
66
+ }
67
+
68
+ function isSuccessCacheShape(value) {
69
+ return isMultiQuotaSuccessCacheShape(value) || isLegacyPercentSuccessCacheShape(value);
22
70
  }
23
71
 
24
72
  function asFiniteNumber(value) {
@@ -18,28 +18,145 @@ function isRateLimitedMessage(value) {
18
18
  return /rate\s*limit|too many requests|too frequent|frequency|\u9650\u6d41|\u9891\u7387|\u8fc7\u4e8e\u9891\u7e41|\u7a0d\u540e\u518d\u8bd5/i.test(value);
19
19
  }
20
20
 
21
- function computeTotal(limit) {
22
- const usage = asFiniteNumber(limit.usage);
23
- if (usage !== null && usage >= 0) {
24
- return usage;
21
+ function clampPercent(value) {
22
+ const percent = asFiniteNumber(value);
23
+ if (percent === null) {
24
+ return null;
25
25
  }
26
26
 
27
- const remaining = asFiniteNumber(limit.remaining);
28
- const currentValue = asFiniteNumber(limit.currentValue);
29
- if (remaining !== null && currentValue !== null) {
30
- return remaining + currentValue;
27
+ return Math.min(100, Math.max(0, percent));
28
+ }
29
+
30
+ function computePercentages(limit) {
31
+ const usage = asFiniteNumber(limit?.usage);
32
+ const remaining = asFiniteNumber(limit?.remaining);
33
+ const currentValue = asFiniteNumber(limit?.currentValue);
34
+ const totalFromParts =
35
+ remaining !== null && currentValue !== null ? remaining + currentValue : null;
36
+ const total = totalFromParts !== null && totalFromParts > 0 ? totalFromParts : usage;
37
+
38
+ if (total !== null && total > 0) {
39
+ if (remaining !== null && remaining >= 0 && remaining <= total) {
40
+ const leftPercent = clampPercent(Math.round((remaining / total) * 100));
41
+ if (leftPercent !== null) {
42
+ return {
43
+ leftPercent,
44
+ usedPercent: 100 - leftPercent
45
+ };
46
+ }
47
+ }
48
+
49
+ if (currentValue !== null && currentValue >= 0 && currentValue <= total) {
50
+ const usedPercent = clampPercent(Math.round((currentValue / total) * 100));
51
+ if (usedPercent !== null) {
52
+ return {
53
+ leftPercent: 100 - usedPercent,
54
+ usedPercent
55
+ };
56
+ }
57
+ }
31
58
  }
32
59
 
33
- return null;
60
+ // Legacy token payloads only expose `percentage`; historically this has
61
+ // represented used percent for GLM quota responses.
62
+ const usedPercent = clampPercent(limit?.percentage);
63
+ if (usedPercent === null) {
64
+ return null;
65
+ }
66
+
67
+ return {
68
+ leftPercent: 100 - usedPercent,
69
+ usedPercent
70
+ };
34
71
  }
35
72
 
36
- function clampPercent(value) {
37
- const percent = asFiniteNumber(value);
38
- if (percent === null) {
73
+ function normalizeTokenQuota(key, limit) {
74
+ const percentages = computePercentages(limit);
75
+ if (!percentages) {
39
76
  return null;
40
77
  }
41
78
 
42
- return Math.min(100, Math.max(0, percent));
79
+ const nextResetTime = asFiniteNumber(limit?.nextResetTime);
80
+
81
+ return {
82
+ key,
83
+ leftPercent: percentages.leftPercent,
84
+ usedPercent: percentages.usedPercent,
85
+ ...(nextResetTime !== null ? { nextResetTime } : {})
86
+ };
87
+ }
88
+
89
+ function sortTokenLimitsByResetTime(limits) {
90
+ return [...limits].sort((left, right) => {
91
+ const leftReset = asFiniteNumber(left?.nextResetTime);
92
+ const rightReset = asFiniteNumber(right?.nextResetTime);
93
+
94
+ if (leftReset !== null && rightReset !== null && leftReset !== rightReset) {
95
+ return leftReset - rightReset;
96
+ }
97
+
98
+ if (leftReset !== null && rightReset === null) {
99
+ return -1;
100
+ }
101
+
102
+ if (leftReset === null && rightReset !== null) {
103
+ return 1;
104
+ }
105
+
106
+ return (left?._index ?? 0) - (right?._index ?? 0);
107
+ });
108
+ }
109
+
110
+ function pickSecondaryTokenLimit(tokenLimits, primaryLimit) {
111
+ const candidates = tokenLimits.filter((limit) => limit?._index !== primaryLimit?._index);
112
+ if (candidates.length === 0) {
113
+ return null;
114
+ }
115
+
116
+ return sortTokenLimitsByResetTime(candidates)[0] ?? null;
117
+ }
118
+
119
+ function pickPrimaryAndSecondaryTokenLimits(limits) {
120
+ const tokenLimits = limits
121
+ .filter((limit) => limit?.type === "TOKENS_LIMIT")
122
+ .map((limit, index) => ({ ...limit, _index: index }));
123
+
124
+ if (tokenLimits.length === 0) {
125
+ return [];
126
+ }
127
+
128
+ const explicitFiveHour = tokenLimits.find((limit) => limit?.number === 5) ?? null;
129
+ if (explicitFiveHour) {
130
+ const weekCandidate = pickSecondaryTokenLimit(tokenLimits, explicitFiveHour);
131
+ return [explicitFiveHour, weekCandidate].filter(Boolean);
132
+ }
133
+
134
+ if (tokenLimits.length === 1) {
135
+ return tokenLimits;
136
+ }
137
+
138
+ return sortTokenLimitsByResetTime(tokenLimits).slice(0, 2);
139
+ }
140
+
141
+ function buildTokenQuotas(limits) {
142
+ const selectedLimits = pickPrimaryAndSecondaryTokenLimits(limits);
143
+ if (selectedLimits.length === 0) {
144
+ return [];
145
+ }
146
+
147
+ const quotas = [];
148
+ const [fiveHourLimit, weekLimit] = selectedLimits;
149
+ const fiveHourQuota = normalizeTokenQuota("token_5h", fiveHourLimit);
150
+ if (fiveHourQuota) {
151
+ quotas.push(fiveHourQuota);
152
+ }
153
+
154
+ const weekQuota = normalizeTokenQuota("token_week", weekLimit);
155
+ if (weekQuota) {
156
+ quotas.push(weekQuota);
157
+ }
158
+
159
+ return quotas;
43
160
  }
44
161
 
45
162
  export function parseQuotaResponse(response) {
@@ -70,45 +187,23 @@ export function parseQuotaResponse(response) {
70
187
 
71
188
  const level = typeof payload.data?.level === "string" ? payload.data.level : "";
72
189
  const limits = Array.isArray(payload.data?.limits) ? payload.data.limits : [];
73
- const rollingWindowLimit = limits.find(
74
- (limit) => limit?.type === "TOKENS_LIMIT" && limit?.number === 5
75
- );
76
-
77
- if (rollingWindowLimit) {
78
- const usedPercent = clampPercent(rollingWindowLimit.percentage);
79
- const nextResetTime = asFiniteNumber(rollingWindowLimit.nextResetTime);
80
-
81
- if (usedPercent !== null && nextResetTime !== null) {
82
- return {
83
- kind: "success",
84
- level,
85
- display: "percent",
86
- leftPercent: 100 - usedPercent,
87
- usedPercent,
88
- nextResetTime
89
- };
90
- }
91
- }
92
-
93
- const timeLimit = limits.find((limit) => limit?.type === "TIME_LIMIT" && limit?.unit === 5);
94
- if (!timeLimit) {
190
+ const quotas = buildTokenQuotas(limits);
191
+ if (quotas.length === 0) {
95
192
  return { kind: "unavailable" };
96
193
  }
97
194
 
98
- const remaining = asFiniteNumber(timeLimit.remaining);
99
- const total = computeTotal(timeLimit);
100
- const nextResetTime = asFiniteNumber(timeLimit.nextResetTime);
101
-
102
- if (remaining === null || total === null || nextResetTime === null) {
103
- return { kind: "unavailable" };
104
- }
195
+ const primaryQuota = quotas[0];
105
196
 
106
197
  return {
107
198
  kind: "success",
108
199
  level,
109
- display: "absolute",
110
- remaining,
111
- total,
112
- nextResetTime
200
+ display: "percent",
201
+ leftPercent: primaryQuota.leftPercent,
202
+ usedPercent: primaryQuota.usedPercent,
203
+ ...(Number.isFinite(primaryQuota.nextResetTime)
204
+ ? { nextResetTime: primaryQuota.nextResetTime }
205
+ : {}),
206
+ primaryQuotaKey: primaryQuota.key,
207
+ quotas
113
208
  };
114
209
  }
@@ -1,6 +1,4 @@
1
1
  import {
2
- AUTH_EXPIRED_TEXT,
3
- QUOTA_UNAVAILABLE_TEXT,
4
2
  normalizeDisplayMode,
5
3
  normalizePalette,
6
4
  normalizeStatusStyle,
@@ -26,76 +24,131 @@ function createErrorSegments(model) {
26
24
  ];
27
25
  }
28
26
 
29
- function createTextSegments(model, displayMode) {
27
+ function createQuotaTextSegments(quota, displayMode, tone) {
30
28
  const mode = normalizeDisplayMode(displayMode);
31
- const severityTone = getSeverityTone(model);
32
29
 
33
- if (mode === "used" && model.usedText) {
30
+ if (mode === "used") {
34
31
  return [
35
- { text: model.levelLabel, tone: "label" },
36
- { text: " | 5h used ", tone: "muted" },
37
- { text: model.usedText, tone: severityTone },
38
- { text: " | reset ", tone: "muted" },
39
- { text: model.resetText, tone: "reset" }
32
+ { text: `${quota.label} used `, tone: "muted" },
33
+ { text: quota.usedText, tone }
40
34
  ];
41
35
  }
42
36
 
43
- if (mode === "both" && model.leftText && model.usedText) {
37
+ if (mode === "both") {
44
38
  return [
45
- { text: model.levelLabel, tone: "label" },
46
- { text: " | 5h left ", tone: "muted" },
47
- { text: model.leftText, tone: severityTone },
39
+ { text: `${quota.label} left `, tone: "muted" },
40
+ { text: quota.leftText, tone },
48
41
  { text: " used ", tone: "muted" },
49
- { text: model.usedText, tone: severityTone },
50
- { text: " | reset ", tone: "muted" },
51
- { text: model.resetText, tone: "reset" }
42
+ { text: quota.usedText, tone }
52
43
  ];
53
44
  }
54
45
 
55
- if (model.leftText) {
56
- return [
57
- { text: model.levelLabel, tone: "label" },
58
- { text: " | 5h left ", tone: "muted" },
59
- { text: model.leftText, tone: severityTone },
60
- { text: " | reset ", tone: "muted" },
61
- { text: model.resetText, tone: "reset" }
62
- ];
46
+ return [
47
+ { text: `${quota.label} `, tone: "muted" },
48
+ { text: quota.leftText, tone }
49
+ ];
50
+ }
51
+
52
+ function appendSecondarySegments(segments, model) {
53
+ if (!model.secondaryQuota) {
54
+ return segments;
63
55
  }
64
56
 
65
- return createErrorSegments({ kind: "unavailable" });
57
+ return [
58
+ ...segments,
59
+ { text: " | ", tone: "muted" },
60
+ { text: `${model.secondaryQuota.label} `, tone: "muted" },
61
+ { text: model.secondaryQuota.leftText, tone: "plain" }
62
+ ];
66
63
  }
67
64
 
68
- function createCompactSegments(model) {
69
- if (!Number.isFinite(model.leftPercent)) {
70
- return createErrorSegments({ kind: "unavailable" });
65
+ function appendResetSegments(segments, model) {
66
+ if (!model.resetText) {
67
+ return segments;
71
68
  }
72
69
 
73
70
  return [
74
- { text: `${model.compactLabel} `, tone: "label" },
75
- { text: `${model.leftPercent}%`, tone: getSeverityTone(model) },
76
- { text: " | ", tone: "muted" },
71
+ ...segments,
72
+ { text: " | reset ", tone: "muted" },
77
73
  { text: model.resetText, tone: "reset" }
78
74
  ];
79
75
  }
80
76
 
77
+ function createTextSegments(model, displayMode) {
78
+ const severityTone = getSeverityTone(model);
79
+
80
+ return appendResetSegments(
81
+ appendSecondarySegments(
82
+ [
83
+ { text: model.levelLabel, tone: "label" },
84
+ { text: " | ", tone: "muted" },
85
+ ...createQuotaTextSegments(model.primaryQuota, displayMode, severityTone)
86
+ ],
87
+ model
88
+ ),
89
+ model
90
+ );
91
+ }
92
+
93
+ function createCompactSegments(model) {
94
+ const severityTone = getSeverityTone(model);
95
+ let segments;
96
+
97
+ if (model.secondaryQuota) {
98
+ segments = [
99
+ { text: `${model.compactLabel} `, tone: "label" },
100
+ { text: `${model.primaryQuota.compactLabel} `, tone: "muted" },
101
+ { text: model.primaryQuota.leftText, tone: severityTone },
102
+ { text: " ", tone: "plain" },
103
+ { text: `${model.secondaryQuota.compactLabel} `, tone: "muted" },
104
+ { text: model.secondaryQuota.leftText, tone: "plain" }
105
+ ];
106
+ } else {
107
+ segments = [
108
+ { text: `${model.compactLabel} `, tone: "label" },
109
+ { text: model.primaryQuota.leftText, tone: severityTone }
110
+ ];
111
+ }
112
+
113
+ if (model.resetText) {
114
+ segments.push({ text: " | ", tone: "muted" }, { text: model.resetText, tone: "reset" });
115
+ }
116
+
117
+ return segments;
118
+ }
119
+
81
120
  function createBarSegments(model, barWidth) {
82
- if (!Number.isFinite(model.leftPercent) || !Number.isFinite(model.usedPercent)) {
121
+ if (
122
+ !Number.isFinite(model.primaryQuota?.leftPercent) ||
123
+ !Number.isFinite(model.primaryQuota?.usedPercent)
124
+ ) {
83
125
  return createErrorSegments({ kind: "unavailable" });
84
126
  }
85
127
 
86
- const bar = buildBar(model.usedPercent, barWidth);
128
+ const bar = buildBar(model.primaryQuota.usedPercent, barWidth);
87
129
  const severityTone = getSeverityTone(model);
88
-
89
- return [
130
+ const segments = [
90
131
  { text: model.levelLabel, tone: "label" },
91
132
  { text: " ", tone: "plain" },
92
133
  { text: bar.filledText, tone: severityTone },
93
134
  { text: bar.emptyText, tone: "barEmpty" },
94
135
  { text: " ", tone: "plain" },
95
- { text: `${model.leftPercent}%`, tone: severityTone },
96
- { text: " | ", tone: "muted" },
97
- { text: model.resetText, tone: "reset" }
136
+ { text: model.primaryQuota.leftText, tone: severityTone }
98
137
  ];
138
+
139
+ if (model.secondaryQuota) {
140
+ segments.push(
141
+ { text: " | ", tone: "muted" },
142
+ { text: `${model.secondaryQuota.compactLabel} `, tone: "muted" },
143
+ { text: model.secondaryQuota.leftText, tone: "plain" }
144
+ );
145
+ }
146
+
147
+ if (model.resetText) {
148
+ segments.push({ text: " | ", tone: "muted" }, { text: model.resetText, tone: "reset" });
149
+ }
150
+
151
+ return segments;
99
152
  }
100
153
 
101
154
  export function formatStatus(result, options = {}) {
@@ -2,6 +2,11 @@ function padTwoDigits(value) {
2
2
  return String(value).padStart(2, "0");
3
3
  }
4
4
 
5
+ const QUOTA_LABELS = {
6
+ token_5h: { text: "5h", compact: "5h" },
7
+ token_week: { text: "week", compact: "W" }
8
+ };
9
+
5
10
  function formatLevel(level) {
6
11
  if (!level) {
7
12
  return "GLM";
@@ -11,6 +16,10 @@ function formatLevel(level) {
11
16
  }
12
17
 
13
18
  function formatResetTime(timestampMs) {
19
+ if (!Number.isFinite(timestampMs)) {
20
+ return null;
21
+ }
22
+
14
23
  const date = new Date(timestampMs);
15
24
  if (Number.isNaN(date.getTime())) {
16
25
  return null;
@@ -35,6 +44,51 @@ function getSeverity(leftPercent) {
35
44
  return "danger";
36
45
  }
37
46
 
47
+ function getQuotaLabels(key) {
48
+ return QUOTA_LABELS[key] || { text: key || "quota", compact: key || "Q" };
49
+ }
50
+
51
+ function normalizeQuota(quota) {
52
+ if (!quota || typeof quota !== "object" || !Number.isFinite(quota.leftPercent)) {
53
+ return null;
54
+ }
55
+
56
+ const usedPercent = Number.isFinite(quota.usedPercent)
57
+ ? quota.usedPercent
58
+ : Math.max(0, Math.min(100, 100 - quota.leftPercent));
59
+ const labels = getQuotaLabels(quota.key);
60
+
61
+ return {
62
+ key: quota.key || "token_5h",
63
+ label: labels.text,
64
+ compactLabel: labels.compact,
65
+ leftPercent: quota.leftPercent,
66
+ usedPercent,
67
+ leftText: `${quota.leftPercent}%`,
68
+ usedText: `${usedPercent}%`,
69
+ nextResetTime: Number.isFinite(quota.nextResetTime) ? quota.nextResetTime : null
70
+ };
71
+ }
72
+
73
+ function normalizeQuotasFromResult(result) {
74
+ if (Array.isArray(result.quotas) && result.quotas.length > 0) {
75
+ return result.quotas.map(normalizeQuota).filter(Boolean);
76
+ }
77
+
78
+ if (result.display === "percent" && Number.isFinite(result.leftPercent)) {
79
+ return [
80
+ normalizeQuota({
81
+ key: "token_5h",
82
+ leftPercent: result.leftPercent,
83
+ usedPercent: result.usedPercent,
84
+ nextResetTime: result.nextResetTime
85
+ })
86
+ ].filter(Boolean);
87
+ }
88
+
89
+ return [];
90
+ }
91
+
38
92
  export function buildStatusViewModel(result) {
39
93
  if (!result || typeof result !== "object") {
40
94
  return { kind: "unavailable" };
@@ -48,59 +102,26 @@ export function buildStatusViewModel(result) {
48
102
  return { kind: "unavailable" };
49
103
  }
50
104
 
51
- const resetText = formatResetTime(result.nextResetTime);
52
- if (!resetText) {
105
+ const quotas = normalizeQuotasFromResult(result);
106
+ if (quotas.length === 0) {
53
107
  return { kind: "unavailable" };
54
108
  }
55
109
 
56
- if (result.display === "percent") {
57
- return {
58
- kind: "success",
59
- display: "percent",
60
- levelLabel: formatLevel(result.level),
61
- compactLabel: "GLM",
62
- resetText,
63
- leftPercent: result.leftPercent,
64
- usedPercent: result.usedPercent,
65
- leftText: Number.isFinite(result.leftPercent) ? `${result.leftPercent}%` : null,
66
- usedText: Number.isFinite(result.usedPercent) ? `${result.usedPercent}%` : null,
67
- severity: getSeverity(result.leftPercent)
68
- };
69
- }
70
-
71
- if (result.display === "absolute") {
72
- // Absolute quota responses are normalized into the same percent-oriented
73
- // view model so the renderer can stay display-format-focused.
74
- const usedValue =
75
- Number.isFinite(result.total) && Number.isFinite(result.remaining)
76
- ? result.total - result.remaining
77
- : null;
78
- const leftPercent =
79
- Number.isFinite(result.total) && result.total > 0 && Number.isFinite(result.remaining)
80
- ? Math.round((result.remaining / result.total) * 100)
81
- : null;
82
- const usedPercent =
83
- Number.isFinite(leftPercent) ? Math.max(0, Math.min(100, 100 - leftPercent)) : null;
84
-
85
- return {
86
- kind: "success",
87
- display: "absolute",
88
- levelLabel: formatLevel(result.level),
89
- compactLabel: "GLM",
90
- resetText,
91
- leftPercent,
92
- usedPercent,
93
- leftText:
94
- Number.isFinite(result.remaining) && Number.isFinite(result.total)
95
- ? `${result.remaining}/${result.total}`
96
- : null,
97
- usedText:
98
- Number.isFinite(usedValue) && Number.isFinite(result.total)
99
- ? `${usedValue}/${result.total}`
100
- : null,
101
- severity: getSeverity(leftPercent)
102
- };
103
- }
104
-
105
- return { kind: "unavailable" };
110
+ const primaryQuota =
111
+ quotas.find((quota) => quota.key === result.primaryQuotaKey) ||
112
+ quotas.find((quota) => quota.key === "token_5h") ||
113
+ quotas[0];
114
+ const secondaryQuota = quotas.find((quota) => quota !== primaryQuota) || null;
115
+
116
+ return {
117
+ kind: "success",
118
+ levelLabel: formatLevel(result.level),
119
+ compactLabel: "GLM",
120
+ primaryQuota,
121
+ secondaryQuota,
122
+ leftPercent: primaryQuota.leftPercent,
123
+ usedPercent: primaryQuota.usedPercent,
124
+ resetText: formatResetTime(primaryQuota.nextResetTime),
125
+ severity: getSeverity(primaryQuota.leftPercent)
126
+ };
106
127
  }
@@ -5,6 +5,8 @@ import path from "node:path";
5
5
  import {
6
6
  DEFAULT_BAR_WIDTH,
7
7
  DEFAULT_CACHE_TTL_MS,
8
+ DEFAULT_CN_BASE_URL,
9
+ DEFAULT_INTL_BASE_URL,
8
10
  DEFAULT_DISPLAY_MODE,
9
11
  DEFAULT_PALETTE,
10
12
  DEFAULT_QUOTA_URL,
@@ -45,14 +47,19 @@ function deriveQuotaUrl(baseUrl) {
45
47
 
46
48
  try {
47
49
  const parsedBaseUrl = new URL(baseUrl);
48
- const baseDomain = `${parsedBaseUrl.protocol}//${parsedBaseUrl.host}`;
50
+ const host = parsedBaseUrl.host;
51
+
52
+ if (host.includes("api.z.ai")) {
53
+ return `${DEFAULT_INTL_BASE_URL}/api/monitor/usage/quota/limit`;
54
+ }
49
55
 
50
56
  if (
51
- parsedBaseUrl.host.includes("api.z.ai") ||
52
- parsedBaseUrl.host.includes("open.bigmodel.cn") ||
53
- parsedBaseUrl.host.includes("dev.bigmodel.cn")
57
+ host.includes("open.bigmodel.cn") ||
58
+ host.includes("dev.bigmodel.cn") ||
59
+ host === "bigmodel.cn" ||
60
+ host.endsWith(".bigmodel.cn")
54
61
  ) {
55
- return `${baseDomain}/api/monitor/usage/quota/limit`;
62
+ return `${DEFAULT_CN_BASE_URL}/api/monitor/usage/quota/limit`;
56
63
  }
57
64
  } catch {
58
65
  return "";
@@ -1,4 +1,6 @@
1
1
  export const DEFAULT_QUOTA_URL = "https://bigmodel.cn/api/monitor/usage/quota/limit";
2
+ export const DEFAULT_CN_BASE_URL = "https://open.bigmodel.cn";
3
+ export const DEFAULT_INTL_BASE_URL = "https://api.z.ai";
2
4
  export const DEFAULT_TIMEOUT_MS = 5000;
3
5
  export const DEFAULT_CACHE_TTL_MS = 300_000;
4
6
  export const DEFAULT_DISPLAY_MODE = "left";
@@ -13,8 +15,8 @@ export const THEMES = ["plain", "ansi"];
13
15
  export const PALETTES = ["dark", "mono"];
14
16
 
15
17
  export const STATUS_BAR_CHARACTERS = {
16
- filled: "■",
17
- empty: "□"
18
+ filled: "█",
19
+ empty: "░"
18
20
  };
19
21
 
20
22
  export const TOOL_CONFIG_SCHEMA_VERSION = 1;