dsh-llm-local-token 1.4.1 → 1.5.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/README.md +30 -3
- package/README.zh.md +26 -3
- package/lib/client.js +16 -5
- package/lib/glm.js +165 -0
- package/lib/index.js +41 -4
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -13,10 +13,14 @@ usable model routes inside DSH.
|
|
|
13
13
|
Both routes appear in the model picker as soon as the plugin loads. A route whose credential is
|
|
14
14
|
missing is skipped instead of failing the boot.
|
|
15
15
|
|
|
16
|
+
The usage badge additionally reports a **GLM Coding Plan** subscription, which DSH already serves
|
|
17
|
+
through pi-ai's own `zai-coding-cn` route — the plugin adds the quota, not a second route, so the
|
|
18
|
+
model picker gains no duplicate. See [Subscription usage badge](#subscription-usage-badge).
|
|
19
|
+
|
|
16
20
|
<table>
|
|
17
21
|
<tr>
|
|
18
22
|
<td align="center" width="50%"><sub>Both subscriptions as routes in the model picker</sub><br><img src="https://raw.githubusercontent.com/tianxia--/dsh-llm-local-token/main/docs/model-routes.png" alt="The DSH model picker listing OpenAI Codex (local token) and Claude (local token) groups" width="330"></td>
|
|
19
|
-
<td align="center" width="50%"><sub>Subscription usage
|
|
23
|
+
<td align="center" width="50%"><sub>Subscription usage for every subscription the plugin can see</sub><br><img src="https://raw.githubusercontent.com/tianxia--/dsh-llm-local-token/main/docs/subscription-usage.png" alt="Subscription usage popover showing GLM Coding Plan, OpenAI Codex and Claude quota windows" width="400"></td>
|
|
20
24
|
</tr>
|
|
21
25
|
</table>
|
|
22
26
|
|
|
@@ -84,19 +88,42 @@ All keys are optional; the defaults match a stock CLI install.
|
|
|
84
88
|
| `usageProbeStartupDelayMs` | `20000` | Delay before the probe that runs at boot. A clock schedule only fires while dsh happens to be running, so boot is its own trigger. |
|
|
85
89
|
| `usageProbeCodexModel` | `gpt-5.6-terra` | Model the Codex probe names; only a vehicle for the headers. |
|
|
86
90
|
| `usageProbeAnthropicModel` | `claude-haiku-4-5-20251001` | Model the Anthropic probe names; only a vehicle for the headers. |
|
|
91
|
+
| `glmQuota` | `true` | Report GLM Coding Plan quota. No route is registered either way — DSH already serves GLM. |
|
|
92
|
+
| `glmApiKey` | — | GLM token, overriding every discovered source. |
|
|
93
|
+
| `glmApiKeyEnv` | `ZAI_CODING_CN_API_KEY` | Environment variable and `$DSH_HOME/.credentials.yaml` ref consulted for the GLM token. |
|
|
94
|
+
| `glmBaseDomain` | `https://open.bigmodel.cn` | Monitor host. `https://api.z.ai` is the international front; both answer the same body for the same account. |
|
|
87
95
|
|
|
88
96
|
## Subscription usage badge
|
|
89
97
|
|
|
90
|
-
|
|
98
|
+
Codex and Claude state their quota in response headers, so reading it off a real request costs
|
|
91
99
|
nothing. A route you never call has nothing to report, though — so the plugin also refreshes on a
|
|
92
100
|
schedule, with one deliberately tiny request per provider (16 input tokens for Codex, 9 for
|
|
93
101
|
Anthropic) that carries no prompt, skills, tools or history and is never stored. A badge appears in
|
|
94
102
|
the composer bar next to the context ring; click it for the breakdown.
|
|
95
103
|
|
|
96
|
-
| Provider |
|
|
104
|
+
| Provider | Source | Shown |
|
|
97
105
|
| --- | --- | --- |
|
|
98
106
|
| `openai-codex` | `x-codex-primary-*`, `x-codex-secondary-*`, `x-codex-plan-type`, `x-codex-credits-balance` | plan, used % per window, reset countdown, credit balance |
|
|
99
107
|
| `anthropic` | `anthropic-ratelimit-unified-{5h,7d}-{utilization,reset,status}` | used % for the 5-hour and 7-day windows, reset countdown |
|
|
108
|
+
| `zai-coding-cn` | `GET /api/monitor/usage/quota/limit` | plan level, used % for the 5-hour and weekly token windows, and the MCP tool-call quota |
|
|
109
|
+
|
|
110
|
+
GLM is the odd one out and deliberately so. DSH already serves it through pi-ai's built-in
|
|
111
|
+
`zai-coding-cn` route, so this plugin contributes the quota half only — registering a route would
|
|
112
|
+
put a duplicate GLM in the model picker. Its numbers come from the subscription's own monitor
|
|
113
|
+
endpoint rather than response headers, so there is no probe request to pay for. modlens re-exposes
|
|
114
|
+
every pi-ai route under a `modlens-` prefix as a separate picker entry, and the badge treats
|
|
115
|
+
`modlens-zai-coding-cn` as the same subscription.
|
|
116
|
+
|
|
117
|
+
GLM's credential is resolved in the order that keeps the number honest — the badge has to report
|
|
118
|
+
the subscription the calls are actually billed to:
|
|
119
|
+
|
|
120
|
+
1. `glmApiKey` in this plugin's config
|
|
121
|
+
2. the `ZAI_CODING_CN_API_KEY` environment variable
|
|
122
|
+
3. the same-named ref in `$DSH_HOME/.credentials.yaml` — what DSH itself calls with
|
|
123
|
+
4. `~/.zcode/v2/credentials.json` → `oauth:bigmodel:access_token`, for a local `zcode` sign-in
|
|
124
|
+
|
|
125
|
+
With none of those present the GLM row is skipped, exactly like a missing Codex or Claude
|
|
126
|
+
credential. Set `glmQuota: false` to switch it off outright.
|
|
100
127
|
|
|
101
128
|
The badge is green under 60%, amber under 85%, red above. Any reading older than a minute carries
|
|
102
129
|
its age, because a 5-hour window resets about five times a day and a stale number that looks live
|
package/README.zh.md
CHANGED
|
@@ -11,10 +11,14 @@ Codex CLI 或 Claude Code,这些订阅就会变成 DSH 里可选的模型路
|
|
|
11
11
|
|
|
12
12
|
插件加载后模型直接出现在模型选择器里。缺少凭据的路由会被跳过,不会导致启动失败。
|
|
13
13
|
|
|
14
|
+
用量徽标另外还会报告 **GLM Coding Plan** 的订阅额度。GLM 的调用 DSH 已经通过 pi-ai 自带的
|
|
15
|
+
`zai-coding-cn` 路由提供了,所以本插件只补上额度那一半,不会再注册一条路由——模型选择器里
|
|
16
|
+
不会多出一个重复的 GLM。详见[订阅用量徽标](#订阅用量徽标)。
|
|
17
|
+
|
|
14
18
|
<table>
|
|
15
19
|
<tr>
|
|
16
20
|
<td align="center" width="50%"><sub>两份订阅都成了模型选择器里的路由</sub><br><img src="https://raw.githubusercontent.com/tianxia--/dsh-llm-local-token/main/docs/model-routes.png" alt="DSH 模型选择器中的 OpenAI Codex (local token) 与 Claude (local token) 分组" width="330"></td>
|
|
17
|
-
<td align="center" width="50%"><sub
|
|
21
|
+
<td align="center" width="50%"><sub>订阅用量:插件能看到的每一份订阅</sub><br><img src="https://raw.githubusercontent.com/tianxia--/dsh-llm-local-token/main/docs/subscription-usage.png" alt="订阅用量弹层,显示 GLM Coding Plan、OpenAI Codex 与 Claude 的配额窗口" width="400"></td>
|
|
18
22
|
</tr>
|
|
19
23
|
</table>
|
|
20
24
|
|
|
@@ -79,17 +83,36 @@ agent-default-model:
|
|
|
79
83
|
| `usageProbeStartupDelayMs` | `20000` | 启动探测的延迟。固定时间点只在 dsh 恰好运行时才触发,所以启动本身也是一个触发点。 |
|
|
80
84
|
| `usageProbeCodexModel` | `gpt-5.6-terra` | Codex 探测使用的模型,仅作为拿响应头的载体。 |
|
|
81
85
|
| `usageProbeAnthropicModel` | `claude-haiku-4-5-20251001` | Anthropic 探测使用的模型,仅作为拿响应头的载体。 |
|
|
86
|
+
| `glmQuota` | `true` | 是否报告 GLM Coding Plan 额度。无论开关,都不会注册路由 —— DSH 已经自带 GLM 路由。 |
|
|
87
|
+
| `glmApiKey` | — | 直接指定 GLM token,优先级高于所有自动发现的来源。 |
|
|
88
|
+
| `glmApiKeyEnv` | `ZAI_CODING_CN_API_KEY` | 查找 GLM token 时使用的环境变量名,同时也是 `$DSH_HOME/.credentials.yaml` 里的 ref 名。 |
|
|
89
|
+
| `glmBaseDomain` | `https://open.bigmodel.cn` | 额度接口所在域名。国际站是 `https://api.z.ai`;同一账号下两个域名返回的内容完全一致。 |
|
|
82
90
|
|
|
83
91
|
## 订阅用量徽标
|
|
84
92
|
|
|
85
|
-
|
|
93
|
+
Codex 与 Claude 都在响应头里返回额度状态,所以真实请求顺带就能读到。但你从没调用过的那条路由无从上报 ——
|
|
86
94
|
因此插件还会**定时刷新**:每个 provider 发一个刻意做到最小的请求(Codex 16 个输入 token、Anthropic 9 个),
|
|
87
95
|
不带 prompt、skill、工具与历史,也不落存储。输入框工具条上(上下文圆环旁边)会出现一个徽标,点开看明细。
|
|
88
96
|
|
|
89
|
-
| Provider |
|
|
97
|
+
| Provider | 数据来源 | 展示内容 |
|
|
90
98
|
| --- | --- | --- |
|
|
91
99
|
| `openai-codex` | `x-codex-primary-*`、`x-codex-secondary-*`、`x-codex-plan-type`、`x-codex-credits-balance` | 套餐、各窗口已用百分比、重置倒计时、点数余额 |
|
|
92
100
|
| `anthropic` | `anthropic-ratelimit-unified-{5h,7d}-{utilization,reset,status}` | 5 小时与 7 天窗口的已用百分比、重置倒计时 |
|
|
101
|
+
| `zai-coding-cn` | `GET /api/monitor/usage/quota/limit` | 套餐等级、5 小时与每周 token 窗口的已用百分比、MCP 工具调用配额 |
|
|
102
|
+
|
|
103
|
+
GLM 是个例外,而且是刻意为之。DSH 已经通过 pi-ai 内置的 `zai-coding-cn` 路由提供 GLM 调用,
|
|
104
|
+
所以本插件只补额度那一半 —— 再注册一条路由只会让模型选择器里多出一个重复的 GLM。它的数字来自订阅
|
|
105
|
+
自己的额度接口而不是响应头,因此不需要付出任何探测请求的代价。另外 modlens 会把每条 pi-ai 路由都加上
|
|
106
|
+
`modlens-` 前缀再暴露成一个独立条目,徽标会把 `modlens-zai-coding-cn` 视作同一份订阅。
|
|
107
|
+
|
|
108
|
+
GLM 的凭据按下面的顺序解析,这个顺序保证数字是诚实的 —— 徽标必须报告**真正在扣费的那份订阅**:
|
|
109
|
+
|
|
110
|
+
1. 本插件配置里的 `glmApiKey`
|
|
111
|
+
2. 环境变量 `ZAI_CODING_CN_API_KEY`
|
|
112
|
+
3. `$DSH_HOME/.credentials.yaml` 里的同名 ref —— DSH 自己调用时用的就是它
|
|
113
|
+
4. `~/.zcode/v2/credentials.json` 的 `oauth:bigmodel:access_token`,对应本地 `zcode` 登录
|
|
114
|
+
|
|
115
|
+
一个都找不到就跳过 GLM 那一行,和缺少 Codex / Claude 凭据时的处理一致。设 `glmQuota: false` 可彻底关闭。
|
|
93
116
|
|
|
94
117
|
低于 60% 显示绿色,低于 85% 琥珀色,更高显示红色。超过一分钟的数值会标注**读取时间** —— 5 小时窗口每天
|
|
95
118
|
重置约五次,一个看起来实时的过期数字比没有数字更糟。浏览器端每 15 秒轮询 `GET /llm-local-token/usage`,
|
package/lib/client.js
CHANGED
|
@@ -48,6 +48,7 @@ window.__ModuleLoader__.load({
|
|
|
48
48
|
label: "Quota", title: "Subscription usage",
|
|
49
49
|
"window.primary": "Primary", "window.secondary": "Secondary",
|
|
50
50
|
"window.5h": "5 hours", "window.7d": "7 days",
|
|
51
|
+
"window.tokens": "Tokens", "window.mcp": "MCP calls",
|
|
51
52
|
empty: "No data yet — send one message to read your quota.",
|
|
52
53
|
resets: "resets {when}", credits: "credits: {balance}",
|
|
53
54
|
current: "current",
|
|
@@ -58,6 +59,7 @@ window.__ModuleLoader__.load({
|
|
|
58
59
|
label: "额度", title: "订阅用量",
|
|
59
60
|
"window.primary": "主窗口", "window.secondary": "次窗口",
|
|
60
61
|
"window.5h": "5 小时", "window.7d": "7 天",
|
|
62
|
+
"window.tokens": "Token 额度", "window.mcp": "MCP 调用",
|
|
61
63
|
empty: "暂无数据 —— 发一条消息即可读取额度。",
|
|
62
64
|
resets: "{when}重置", credits: "点数余额:{balance}",
|
|
63
65
|
current: "当前",
|
|
@@ -186,10 +188,16 @@ window.__ModuleLoader__.load({
|
|
|
186
188
|
}, [state.open]);
|
|
187
189
|
|
|
188
190
|
const withData = state.providers.filter((entry) => entry.usage !== null);
|
|
189
|
-
/**
|
|
191
|
+
/**
|
|
192
|
+
* The entry covering the selected model. Matching allows aliases because
|
|
193
|
+
* one subscription can surface under several picker ids — modlens
|
|
194
|
+
* re-exposes every route under a `modlens-` prefix, and that wrapper bills
|
|
195
|
+
* the same account as the route it wraps.
|
|
196
|
+
*/
|
|
197
|
+
const covers = (entry, provider) => entry.provider === provider || (entry.aliases ?? []).includes(provider);
|
|
190
198
|
const active = activeProvider === null
|
|
191
199
|
? undefined
|
|
192
|
-
: state.providers.find((entry) => entry
|
|
200
|
+
: state.providers.find((entry) => covers(entry, activeProvider));
|
|
193
201
|
/**
|
|
194
202
|
* The selected model belongs to some other adapter (a plain API key, a
|
|
195
203
|
* different plugin): this badge owns no quota fact about it, so it says
|
|
@@ -234,13 +242,13 @@ window.__ModuleLoader__.load({
|
|
|
234
242
|
children: [
|
|
235
243
|
jsx("p", { className: "ltk_title", children: t("title") }),
|
|
236
244
|
...ordered.map((entry) => jsxs("div", {
|
|
237
|
-
className: activeProvider !== null && entry
|
|
245
|
+
className: activeProvider !== null && !covers(entry, activeProvider) ? "ltk_prov ltk_provDim" : "ltk_prov",
|
|
238
246
|
children: [
|
|
239
247
|
jsxs("div", {
|
|
240
248
|
className: "ltk_provHead",
|
|
241
249
|
children: [
|
|
242
250
|
jsx("span", { className: "ltk_provName", children: entry.displayName }),
|
|
243
|
-
entry
|
|
251
|
+
activeProvider === null || !covers(entry, activeProvider) ? null : jsx("span", { className: "ltk_cur", children: t("current") }),
|
|
244
252
|
entry.usage?.plan === undefined ? null : jsx("span", { className: "ltk_plan", children: entry.usage.plan }),
|
|
245
253
|
],
|
|
246
254
|
}),
|
|
@@ -248,7 +256,10 @@ window.__ModuleLoader__.load({
|
|
|
248
256
|
? jsx("p", { className: "ltk_hint", children: t("empty") })
|
|
249
257
|
: jsxs("div", {
|
|
250
258
|
children: [
|
|
251
|
-
|
|
259
|
+
// Keyed by position: a provider may report two windows of the
|
|
260
|
+
// same kind (GLM sends two token windows), so the label is not
|
|
261
|
+
// unique and the order is stable.
|
|
262
|
+
...entry.usage.windows.map((w, index) => jsx(UsageRow, { t, entry: w }, index)),
|
|
252
263
|
entry.usage.credits === undefined || entry.usage.creditsUnlimited === true
|
|
253
264
|
? null
|
|
254
265
|
: jsx("p", { className: "ltk_hint", children: t("credits", { balance: String(entry.usage.credits) }) }),
|
package/lib/glm.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// GLM Coding Plan quota.
|
|
2
|
+
//
|
|
3
|
+
// This one is quota-only, and deliberately so: dsh already serves GLM through
|
|
4
|
+
// pi-ai's built-in `zai-coding-cn` route, so registering another would put a
|
|
5
|
+
// duplicate GLM in the model picker. What is missing without this file is the
|
|
6
|
+
// other half — the badge can only speak about a provider it holds a snapshot
|
|
7
|
+
// for, so selecting GLM used to blank it out.
|
|
8
|
+
//
|
|
9
|
+
// The numbers come from the subscription's own monitor endpoint, the same one
|
|
10
|
+
// @z_ai/coding-helper's usage-query skill calls. Verified against both fronts
|
|
11
|
+
// (api.z.ai and open.bigmodel.cn) with the same account: identical bodies down
|
|
12
|
+
// to the reset epochs, so one parser serves either.
|
|
13
|
+
|
|
14
|
+
import { readFile } from "node:fs/promises";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
|
|
18
|
+
/** The pi-ai route id dsh calls GLM through. */
|
|
19
|
+
export const GLM_PROVIDER = "zai-coding-cn";
|
|
20
|
+
/**
|
|
21
|
+
* Other ids the same subscription answers for. modlens re-exposes every pi-ai
|
|
22
|
+
* route under a `modlens-` prefix as a separate picker entry, so the session can
|
|
23
|
+
* report either id for what is one account and one quota.
|
|
24
|
+
*/
|
|
25
|
+
export const GLM_ALIASES = ["modlens-zai-coding-cn"];
|
|
26
|
+
|
|
27
|
+
/** Mainland front; `api.z.ai` serves the same account and the same body. */
|
|
28
|
+
const DEFAULT_BASE = "https://open.bigmodel.cn";
|
|
29
|
+
const QUOTA_PATH = "/api/monitor/usage/quota/limit";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Minutes per `unit` code, for the codes that are actually pinned down.
|
|
33
|
+
*
|
|
34
|
+
* `3` is the hour: `unit: 3, number: 5` was observed resetting in 4.96h, and
|
|
35
|
+
* @z_ai/coding-helper labels that same row "5 Hour".
|
|
36
|
+
*
|
|
37
|
+
* `6` is the week. Both dated windows reset at the same wall-clock second, and
|
|
38
|
+
* taking the monthly row's reset minus one month gives an anchor that lands
|
|
39
|
+
* exactly 3.0000 weeks before the `unit: 6` reset — one subscription anchor
|
|
40
|
+
* explains both only if this is a weekly cycle.
|
|
41
|
+
*
|
|
42
|
+
* `5` (the vendor's "1 Month") is left out on purpose rather than for lack of
|
|
43
|
+
* evidence. It meters MCP tool calls, and a window carrying `windowMinutes`
|
|
44
|
+
* renders as its duration — so mapping it would replace the informative
|
|
45
|
+
* "MCP calls" with a bare "30 days" and lose what the row is about.
|
|
46
|
+
*/
|
|
47
|
+
const UNIT_MINUTES = { 3: 60, 6: 10080 };
|
|
48
|
+
|
|
49
|
+
function readJson(path) {
|
|
50
|
+
return readFile(path, "utf8").then(JSON.parse);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The token for the quota query, in the order that keeps the number honest.
|
|
55
|
+
*
|
|
56
|
+
* The badge must report the subscription the calls are actually billed to, so
|
|
57
|
+
* the key dsh itself calls with wins; the local zcode CLI's OAuth is the
|
|
58
|
+
* fallback for someone who signed in there instead of pasting a key.
|
|
59
|
+
* @returns the token, or undefined when this machine has no GLM credential.
|
|
60
|
+
*/
|
|
61
|
+
export async function resolveGlmToken(config = {}) {
|
|
62
|
+
if (typeof config.glmApiKey === "string" && config.glmApiKey.length > 0) return config.glmApiKey;
|
|
63
|
+
|
|
64
|
+
const fromEnv = process.env[config.glmApiKeyEnv ?? "ZAI_CODING_CN_API_KEY"];
|
|
65
|
+
if (typeof fromEnv === "string" && fromEnv.length > 0) return fromEnv;
|
|
66
|
+
|
|
67
|
+
// dsh's own credential store, where `apiKeyEnv: ZAI_CODING_CN_API_KEY`
|
|
68
|
+
// resolves from. Read the one named ref rather than parsing the document: the
|
|
69
|
+
// file also holds unrelated secrets and has no business being loaded whole.
|
|
70
|
+
try {
|
|
71
|
+
const store = await readFile(config.dshCredentialsPath ?? join(homedir(), ".dsh", ".credentials.yaml"), "utf8");
|
|
72
|
+
const ref = new RegExp(`^\\s*${config.glmApiKeyEnv ?? "ZAI_CODING_CN_API_KEY"}:\\s*(.+)$`, "m").exec(store);
|
|
73
|
+
const value = ref?.[1]?.trim().replace(/^["']|["']$/g, "");
|
|
74
|
+
if (value !== undefined && value.length > 0) return value;
|
|
75
|
+
} catch {
|
|
76
|
+
// Absent or unreadable; the CLI fallbacks below may still answer.
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const zcode = await readJson(config.zcodeCredentialsPath ?? join(homedir(), ".zcode", "v2", "credentials.json"));
|
|
81
|
+
const oauth = zcode?.["oauth:bigmodel:access_token"];
|
|
82
|
+
if (typeof oauth === "string" && oauth.length > 0) return oauth;
|
|
83
|
+
} catch {
|
|
84
|
+
// zcode not installed or never signed in.
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Base domain for the monitor endpoint; the account decides which front. */
|
|
91
|
+
export function glmBaseDomain(config = {}) {
|
|
92
|
+
const base = config.glmBaseDomain ?? DEFAULT_BASE;
|
|
93
|
+
return String(base).replace(/\/+$/, "");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Fetch the quota document.
|
|
98
|
+
* @returns the parsed body, for `glmUsage` to read.
|
|
99
|
+
*/
|
|
100
|
+
export async function probeGlmQuota({ token, baseDomain = DEFAULT_BASE }) {
|
|
101
|
+
const response = await fetch(`${baseDomain}${QUOTA_PATH}`, {
|
|
102
|
+
// The token goes in bare — this endpoint does not take a `Bearer` prefix.
|
|
103
|
+
headers: { authorization: token, "content-type": "application/json" },
|
|
104
|
+
});
|
|
105
|
+
if (!response.ok) throw new Error(`GLM quota HTTP ${response.status}`);
|
|
106
|
+
return response.json();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Name a row by what it meters, since only some window lengths are knowable.
|
|
111
|
+
*
|
|
112
|
+
* Repeats are fine and expected — two token windows of different lengths both
|
|
113
|
+
* answer "tokens". The one whose length is known renders from `windowMinutes`
|
|
114
|
+
* instead, and the panel keys rows by position rather than by this string.
|
|
115
|
+
*/
|
|
116
|
+
function kindOf(limit) {
|
|
117
|
+
return limit?.type === "TIME_LIMIT" ? "mcp" : "tokens";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Read the quota document into the shape the panel renders.
|
|
122
|
+
* @param body - the parsed `/api/monitor/usage/quota/limit` response.
|
|
123
|
+
* @returns the snapshot, or undefined when the body carried no usable limit.
|
|
124
|
+
*/
|
|
125
|
+
export function glmUsage(body) {
|
|
126
|
+
const limits = body?.data?.limits;
|
|
127
|
+
if (!Array.isArray(limits)) return undefined;
|
|
128
|
+
|
|
129
|
+
const windows = [];
|
|
130
|
+
for (const limit of limits) {
|
|
131
|
+
// `percentage` is 0..100 and counts what is USED, not what is left: the
|
|
132
|
+
// metered row reports currentValue 33 of usage 1000 alongside percentage 3.
|
|
133
|
+
const percent = Number(limit?.percentage);
|
|
134
|
+
if (!Number.isFinite(percent)) continue;
|
|
135
|
+
|
|
136
|
+
const unitMinutes = UNIT_MINUTES[limit?.unit];
|
|
137
|
+
const count = Number(limit?.number);
|
|
138
|
+
const minutes = unitMinutes === undefined || !Number.isFinite(count) ? undefined : unitMinutes * count;
|
|
139
|
+
|
|
140
|
+
// `nextResetTime` is epoch MILLISECONDS here, unlike the seconds the Codex
|
|
141
|
+
// and Anthropic headers carry — so it is not scaled on the way in.
|
|
142
|
+
const reset = Number(limit?.nextResetTime);
|
|
143
|
+
|
|
144
|
+
windows.push({
|
|
145
|
+
label: kindOf(limit),
|
|
146
|
+
used: Math.max(0, Math.min(percent / 100, 1)),
|
|
147
|
+
...(minutes === undefined ? {} : { windowMinutes: minutes }),
|
|
148
|
+
...(Number.isFinite(reset) && reset > 0 ? { resetAt: new Date(reset).toISOString() } : {}),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
if (windows.length === 0) return undefined;
|
|
152
|
+
|
|
153
|
+
// Token windows first. The badge headlines the first two, and the endpoint
|
|
154
|
+
// happens to lead with the MCP row — which would put a tool-call count in
|
|
155
|
+
// front of the number that actually moves when you send a message.
|
|
156
|
+
windows.sort((a, b) => Number(a.label === "mcp") - Number(b.label === "mcp"));
|
|
157
|
+
|
|
158
|
+
const level = body?.data?.level;
|
|
159
|
+
return {
|
|
160
|
+
provider: GLM_PROVIDER,
|
|
161
|
+
...(typeof level === "string" && level.length > 0 ? { plan: level } : {}),
|
|
162
|
+
windows,
|
|
163
|
+
at: new Date().toISOString(),
|
|
164
|
+
};
|
|
165
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -17,6 +17,7 @@ import { defaultCodexAuthPath, readCodexAuth, resolveCodexAccessToken } from "./
|
|
|
17
17
|
import { defaultClaudeAuthPath, resolveClaudeAccessToken } from "./claude-keychain.js";
|
|
18
18
|
import { anthropicUsage, codexUsage, withUsageProbe } from "./usage.js";
|
|
19
19
|
import { probeAnthropicQuota, probeCodexQuota } from "./probe.js";
|
|
20
|
+
import { GLM_ALIASES, GLM_PROVIDER, glmBaseDomain, glmUsage, probeGlmQuota, resolveGlmToken } from "./glm.js";
|
|
20
21
|
|
|
21
22
|
/** Plugin identity used by the cordis loader entry. */
|
|
22
23
|
export const name = "llm-local-token";
|
|
@@ -104,6 +105,14 @@ function profileOf(provider, displayName, piProvider, transport) {
|
|
|
104
105
|
*/
|
|
105
106
|
export async function apply(ctx, config = {}) {
|
|
106
107
|
const routes = [];
|
|
108
|
+
/**
|
|
109
|
+
* Providers this plugin reports quota for but does not serve.
|
|
110
|
+
*
|
|
111
|
+
* Kept apart from `routes` on purpose: `registerAdapter` claims every id it is
|
|
112
|
+
* given, so listing a provider dsh already serves would have this plugin fight
|
|
113
|
+
* pi-ai for it. These entries only ever contribute a snapshot.
|
|
114
|
+
*/
|
|
115
|
+
const quotaOnly = [];
|
|
107
116
|
/** Latest quota snapshot per provider id, replaced on every observed response. */
|
|
108
117
|
const usage = new Map();
|
|
109
118
|
/**
|
|
@@ -178,6 +187,29 @@ export async function apply(ctx, config = {}) {
|
|
|
178
187
|
ctx.logger.info(`llm-local-token: Claude local token not usable (${String(error?.message ?? error).slice(0, 160)}); skipping Claude provider`);
|
|
179
188
|
}
|
|
180
189
|
|
|
190
|
+
// ── GLM quota: dsh already serves this route, so only the numbers are ours ─
|
|
191
|
+
//
|
|
192
|
+
// The credential is resolved once here to decide whether to report at all;
|
|
193
|
+
// each probe resolves again so a rotated key or a fresh zcode sign-in lands
|
|
194
|
+
// without a restart.
|
|
195
|
+
if (config.glmQuota !== false) {
|
|
196
|
+
const glmToken = await resolveGlmToken(config);
|
|
197
|
+
if (glmToken === undefined) {
|
|
198
|
+
ctx.logger.info("llm-local-token: no GLM credential found (set glmApiKey, ZAI_CODING_CN_API_KEY, or sign in with zcode); skipping GLM quota");
|
|
199
|
+
} else {
|
|
200
|
+
quotaOnly.push({
|
|
201
|
+
provider: GLM_PROVIDER,
|
|
202
|
+
aliases: GLM_ALIASES,
|
|
203
|
+
displayName: "GLM Coding Plan",
|
|
204
|
+
readQuota: glmUsage,
|
|
205
|
+
probe: async () => probeGlmQuota({
|
|
206
|
+
token: (await resolveGlmToken(config)) ?? glmToken,
|
|
207
|
+
baseDomain: glmBaseDomain(config),
|
|
208
|
+
}),
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
181
213
|
const profiles = () => new Map(routes.map((route) => [
|
|
182
214
|
route.provider,
|
|
183
215
|
profileOf(route.provider, route.displayName, route.piProvider, route.transport),
|
|
@@ -201,6 +233,9 @@ export async function apply(ctx, config = {}) {
|
|
|
201
233
|
|
|
202
234
|
ctx.llm.registerAdapter(routes.map((route) => route.provider), adapter);
|
|
203
235
|
|
|
236
|
+
/** Everything the panel speaks about: routes we serve, plus quota-only entries. */
|
|
237
|
+
const reported = [...routes, ...quotaOnly];
|
|
238
|
+
|
|
204
239
|
// Quota snapshots observed on real responses, newest per provider.
|
|
205
240
|
ctx.inject(["webServer"], (wctx) => {
|
|
206
241
|
wctx.effect(() => wctx.webServer.register({
|
|
@@ -212,8 +247,9 @@ export async function apply(ctx, config = {}) {
|
|
|
212
247
|
return sendJson(res, 404, { error: `unknown route "${path}"` });
|
|
213
248
|
}
|
|
214
249
|
return sendJson(res, 200, {
|
|
215
|
-
providers:
|
|
250
|
+
providers: reported.map((route) => ({
|
|
216
251
|
provider: route.provider,
|
|
252
|
+
...(route.aliases === undefined ? {} : { aliases: route.aliases }),
|
|
217
253
|
displayName: route.displayName,
|
|
218
254
|
usage: usage.get(route.provider) ?? null,
|
|
219
255
|
})),
|
|
@@ -234,12 +270,12 @@ export async function apply(ctx, config = {}) {
|
|
|
234
270
|
// A probe is a convenience and never a dependency: each failure is logged and
|
|
235
271
|
// swallowed, leaving the panel with whatever snapshot it already had.
|
|
236
272
|
const probeOnce = async (reason) => {
|
|
237
|
-
for (const route of
|
|
273
|
+
for (const route of reported) {
|
|
238
274
|
if (typeof route.probe !== "function") continue;
|
|
239
275
|
try {
|
|
240
276
|
const snapshot = route.readQuota(await route.probe());
|
|
241
277
|
if (snapshot === undefined) {
|
|
242
|
-
ctx.logger.info(`llm-local-token: ${route.provider} probe carried no quota
|
|
278
|
+
ctx.logger.info(`llm-local-token: ${route.provider} probe carried no quota data`);
|
|
243
279
|
continue;
|
|
244
280
|
}
|
|
245
281
|
record(snapshot);
|
|
@@ -292,5 +328,6 @@ export async function apply(ctx, config = {}) {
|
|
|
292
328
|
}
|
|
293
329
|
}
|
|
294
330
|
|
|
295
|
-
|
|
331
|
+
const quotaNote = quotaOnly.length === 0 ? "" : `; quota-only: ${quotaOnly.map((entry) => entry.provider).join(", ")}`;
|
|
332
|
+
ctx.logger.info(`llm-local-token: registered ${routes.map((route) => route.provider).join(", ")}${quotaNote} (codex auth: ${codexAuthPath}, codex transport: ${codexTransport})`);
|
|
296
333
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-llm-local-token",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "DeepSeek Harness plugin: serve LLM calls with the OAuth tokens your local Codex CLI and Claude Code already hold, instead of a separately configured API key.",
|
|
3
|
+
"version": "1.5.0",
|
|
4
|
+
"description": "DeepSeek Harness plugin: serve LLM calls with the OAuth tokens your local Codex CLI and Claude Code already hold, instead of a separately configured API key — plus a composer badge showing subscription usage for those routes and for GLM Coding Plan.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|
|
7
7
|
"dsh",
|
|
@@ -10,7 +10,10 @@
|
|
|
10
10
|
"llm",
|
|
11
11
|
"codex",
|
|
12
12
|
"claude",
|
|
13
|
-
"oauth"
|
|
13
|
+
"oauth",
|
|
14
|
+
"glm",
|
|
15
|
+
"zai",
|
|
16
|
+
"quota"
|
|
14
17
|
],
|
|
15
18
|
"license": "MIT",
|
|
16
19
|
"type": "module",
|