dsh-llm-local-token 1.3.2 → 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 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, read from provider rate-limit headers</sub><br><img src="https://raw.githubusercontent.com/tianxia--/dsh-llm-local-token/main/docs/subscription-usage.png" alt="Subscription usage popover showing Claude and OpenAI Codex quota windows" width="400"></td>
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
 
@@ -78,20 +82,52 @@ All keys are optional; the defaults match a stock CLI install.
78
82
  | `claudeKeychainService` | `Claude Code-credentials` | macOS Keychain service holding the Claude OAuth payload |
79
83
  | `requireClaude` | `false` | Fail activation when no Claude credential is found, instead of skipping the route |
80
84
  | `codexTransport` | `"sse"` | Streaming transport for the Codex route: `sse` / `websocket` / `websocket-cached` / `auto`. **The quota badge depends on `sse`**: pi-ai's default `auto` streams over WebSocket, and the `x-codex-*` quota headers exist only on the SSE response, so the badge stays empty under WS. Set `auto` to prefer WebSocket and accept no Codex quota data. |
85
+ | `usageProbe` | `true` | Refresh quota on a schedule with one bare minimal request per provider. Set `false` to keep the panel purely passive. |
86
+ | `usageProbeIntervalHours` | `4` | Hours between probes. Tracks the 5-hour window, which resets about five times a day; `24` is once per day. |
87
+ | `usageProbeAtHour` | — | Local hour `0`–`23` for a once-daily probe at a fixed clock time. Overrides `usageProbeIntervalHours`. |
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. |
89
+ | `usageProbeCodexModel` | `gpt-5.6-terra` | Model the Codex probe names; only a vehicle for the headers. |
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. |
81
95
 
82
96
  ## Subscription usage badge
83
97
 
84
- Both providers return their quota state in response headers, so the plugin reads it for free
85
- no polling, no extra endpoint hits. A badge appears in the composer bar next to the context
86
- ring; click it for the breakdown.
98
+ Codex and Claude state their quota in response headers, so reading it off a real request costs
99
+ nothing. A route you never call has nothing to report, though — so the plugin also refreshes on a
100
+ schedule, with one deliberately tiny request per provider (16 input tokens for Codex, 9 for
101
+ Anthropic) that carries no prompt, skills, tools or history and is never stored. A badge appears in
102
+ the composer bar next to the context ring; click it for the breakdown.
87
103
 
88
- | Provider | Headers read | Shown |
104
+ | Provider | Source | Shown |
89
105
  | --- | --- | --- |
90
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 |
91
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.
92
127
 
93
- The badge is green under 60%, amber under 85%, red above. Usage is whatever the **last real
94
- request** reported, so a freshly started host shows "no data yet" until you send one message.
128
+ The badge is green under 60%, amber under 85%, red above. Any reading older than a minute carries
129
+ its age, because a 5-hour window resets about five times a day and a stale number that looks live
130
+ is worse than none.
95
131
  The browser half polls `GET /llm-local-token/usage` every 15s; that route only reads the
96
132
  in-memory snapshot.
97
133
 
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>订阅用量,来自 provider 的 rate-limit 响应头</sub><br><img src="https://raw.githubusercontent.com/tianxia--/dsh-llm-local-token/main/docs/subscription-usage.png" alt="订阅用量弹层,显示 Claude OpenAI Codex 的配额窗口" width="400"></td>
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
 
@@ -73,19 +77,46 @@ agent-default-model:
73
77
  | `claudeKeychainService` | `Claude Code-credentials` | 存放 Claude OAuth 数据的 Keychain 服务名 |
74
78
  | `requireClaude` | `false` | 为 `true` 时找不到 Claude 凭据就启动失败(而不是跳过) |
75
79
  | `codexTransport` | `"sse"` | Codex 路由的流式通道:`sse` / `websocket` / `websocket-cached` / `auto`。**额度徽标依赖 `sse`**:pi-ai 默认的 `auto` 会走 WebSocket,而 `x-codex-*` 额度响应头只存在于 SSE 响应上,走 WS 时徽标永远是「暂无数据」。想要 WebSocket 就设成 `auto`,代价是没有 Codex 额度数据。 |
80
+ | `usageProbe` | `true` | 是否定时刷新额度(每个 provider 一个最小裸请求)。设 `false` 则完全被动,只读真实请求。 |
81
+ | `usageProbeIntervalHours` | `4` | 探测间隔小时数。对齐 5 小时窗口(每天重置约五次);设 `24` 就是每天一次。 |
82
+ | `usageProbeAtHour` | — | 本地时钟小时 `0`–`23`,在固定时间每天探测一次。设置后覆盖 `usageProbeIntervalHours`。 |
83
+ | `usageProbeStartupDelayMs` | `20000` | 启动探测的延迟。固定时间点只在 dsh 恰好运行时才触发,所以启动本身也是一个触发点。 |
84
+ | `usageProbeCodexModel` | `gpt-5.6-terra` | Codex 探测使用的模型,仅作为拿响应头的载体。 |
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`;同一账号下两个域名返回的内容完全一致。 |
76
90
 
77
91
  ## 订阅用量徽标
78
92
 
79
- 两家 provider 都在响应头里返回额度状态,插件顺带读取即可 —— 不轮询、不额外调接口。输入框工具条上
80
- (上下文圆环旁边)会出现一个徽标,点开看明细。
93
+ Codex Claude 都在响应头里返回额度状态,所以真实请求顺带就能读到。但你从没调用过的那条路由无从上报 ——
94
+ 因此插件还会**定时刷新**:每个 provider 发一个刻意做到最小的请求(Codex 16 个输入 token、Anthropic 9 个),
95
+ 不带 prompt、skill、工具与历史,也不落存储。输入框工具条上(上下文圆环旁边)会出现一个徽标,点开看明细。
81
96
 
82
- | Provider | 读取的响应头 | 展示内容 |
97
+ | Provider | 数据来源 | 展示内容 |
83
98
  | --- | --- | --- |
84
99
  | `openai-codex` | `x-codex-primary-*`、`x-codex-secondary-*`、`x-codex-plan-type`、`x-codex-credits-balance` | 套餐、各窗口已用百分比、重置倒计时、点数余额 |
85
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` 可彻底关闭。
86
116
 
87
- 低于 60% 显示绿色,低于 85% 琥珀色,更高显示红色。数值来自**最近一次真实请求**,所以刚启动时会显示
88
- 「暂无数据」,发一条消息即可。浏览器端每 15 秒轮询 `GET /llm-local-token/usage`,该路由只读内存快照。
117
+ 低于 60% 显示绿色,低于 85% 琥珀色,更高显示红色。超过一分钟的数值会标注**读取时间** —— 5 小时窗口每天
118
+ 重置约五次,一个看起来实时的过期数字比没有数字更糟。浏览器端每 15 秒轮询 `GET /llm-local-token/usage`,
119
+ 该路由只读内存快照。
89
120
 
90
121
  徽标**只显示当前选中模型所属 provider** 的用量:选 Codex 就是 Codex 的窗口,切到 Claude 就换成
91
122
  Claude 的,不会把两家的数字混在一起。选中的模型由别的 adapter 提供(普通 API key、其他插件)时徽标
package/lib/client.js CHANGED
@@ -6,7 +6,7 @@ window.__ModuleLoader__.load({
6
6
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
7
  const jsxRuntime = require("react/jsx-runtime");
8
8
  const react = require("react");
9
- const runtime = require("@deepseek-ai/dsh-client-runtime/client");
9
+ const clientStore = require("@deepseek-ai/dsh-client-store");
10
10
  const jsx = jsxRuntime.jsx;
11
11
  const jsxs = jsxRuntime.jsxs;
12
12
 
@@ -48,19 +48,23 @@ 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",
54
55
  "unit.d": "{n} days", "unit.h": "{n} hours", "unit.m": "{n} min",
56
+ readAt: "read {when} ago",
55
57
  };
56
58
  const zh = {
57
59
  label: "额度", title: "订阅用量",
58
60
  "window.primary": "主窗口", "window.secondary": "次窗口",
59
61
  "window.5h": "5 小时", "window.7d": "7 天",
62
+ "window.tokens": "Token 额度", "window.mcp": "MCP 调用",
60
63
  empty: "暂无数据 —— 发一条消息即可读取额度。",
61
64
  resets: "{when}重置", credits: "点数余额:{balance}",
62
65
  current: "当前",
63
66
  "unit.d": "{n} 天", "unit.h": "{n} 小时", "unit.m": "{n} 分钟",
67
+ readAt: "{when}前读取",
64
68
  };
65
69
 
66
70
  /** Green under 60%, amber under 85%, red above. */
@@ -98,6 +102,24 @@ window.__ModuleLoader__.load({
98
102
  return String(Math.round(seconds / 86400)) + "d";
99
103
  }
100
104
 
105
+ /**
106
+ * Compact "how long ago" for a snapshot instant; blank under a minute.
107
+ *
108
+ * A scheduled probe means a number can be hours old while still looking
109
+ * live, and a 5-hour window resets about five times a day — so the age is
110
+ * load-bearing, not decoration. Returning "" for a fresh read keeps the
111
+ * panel quiet until staleness is the thing worth saying.
112
+ */
113
+ function agoOf(iso) {
114
+ const at = Date.parse(iso ?? "");
115
+ if (!Number.isFinite(at)) return "";
116
+ const seconds = Math.max(0, Math.round((Date.now() - at) / 1000));
117
+ if (seconds < 60) return "";
118
+ if (seconds < 3600) return String(Math.round(seconds / 60)) + "m";
119
+ if (seconds < 86400) return String(Math.round(seconds / 3600)) + "h";
120
+ return String(Math.round(seconds / 86400)) + "d";
121
+ }
122
+
101
123
  function UsageRow({ t, entry }) {
102
124
  const percent = Math.round(entry.used * 100);
103
125
  return jsxs("div", {
@@ -123,12 +145,10 @@ window.__ModuleLoader__.load({
123
145
  function UsageBadge(props) {
124
146
  const { t } = props;
125
147
  const state = props.useLocalTokenUsage((snapshot) => snapshot);
126
- // The provider of the model this session will actually use. The hook only
127
- // exists when the host composed model selection (Web); its presence is
128
- // fixed at registration time, so this call order never changes at runtime.
129
- const activeProvider = typeof props.useModelSelection === "function"
130
- ? props.useModelSelection((snapshot) => snapshot.current?.provider ?? null)
131
- : null;
148
+ // The provider of the model this session will actually use. The controller
149
+ // always supplies this hook backed by a standing empty selection when the
150
+ // host composed no model selection — so the call is unconditional.
151
+ const activeProvider = props.useModelSelection((snapshot) => snapshot.current?.provider ?? null);
132
152
  const wrapRef = react.useRef(null);
133
153
  const [pos, setPos] = react.useState(null);
134
154
  react.useEffect(() => {
@@ -168,10 +188,16 @@ window.__ModuleLoader__.load({
168
188
  }, [state.open]);
169
189
 
170
190
  const withData = state.providers.filter((entry) => entry.usage !== null);
171
- /** The route serving the selected model, when this plugin owns it. */
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);
172
198
  const active = activeProvider === null
173
199
  ? undefined
174
- : state.providers.find((entry) => entry.provider === activeProvider);
200
+ : state.providers.find((entry) => covers(entry, activeProvider));
175
201
  /**
176
202
  * The selected model belongs to some other adapter (a plain API key, a
177
203
  * different plugin): this badge owns no quota fact about it, so it says
@@ -216,13 +242,13 @@ window.__ModuleLoader__.load({
216
242
  children: [
217
243
  jsx("p", { className: "ltk_title", children: t("title") }),
218
244
  ...ordered.map((entry) => jsxs("div", {
219
- className: activeProvider !== null && entry.provider !== activeProvider ? "ltk_prov ltk_provDim" : "ltk_prov",
245
+ className: activeProvider !== null && !covers(entry, activeProvider) ? "ltk_prov ltk_provDim" : "ltk_prov",
220
246
  children: [
221
247
  jsxs("div", {
222
248
  className: "ltk_provHead",
223
249
  children: [
224
250
  jsx("span", { className: "ltk_provName", children: entry.displayName }),
225
- entry.provider !== activeProvider ? null : jsx("span", { className: "ltk_cur", children: t("current") }),
251
+ activeProvider === null || !covers(entry, activeProvider) ? null : jsx("span", { className: "ltk_cur", children: t("current") }),
226
252
  entry.usage?.plan === undefined ? null : jsx("span", { className: "ltk_plan", children: entry.usage.plan }),
227
253
  ],
228
254
  }),
@@ -230,10 +256,16 @@ window.__ModuleLoader__.load({
230
256
  ? jsx("p", { className: "ltk_hint", children: t("empty") })
231
257
  : jsxs("div", {
232
258
  children: [
233
- ...entry.usage.windows.map((w) => jsx(UsageRow, { t, entry: w }, w.label)),
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)),
234
263
  entry.usage.credits === undefined || entry.usage.creditsUnlimited === true
235
264
  ? null
236
265
  : jsx("p", { className: "ltk_hint", children: t("credits", { balance: String(entry.usage.credits) }) }),
266
+ agoOf(entry.usage.at).length === 0
267
+ ? null
268
+ : jsx("p", { className: "ltk_hint", children: t("readAt", { when: agoOf(entry.usage.at) }) }),
237
269
  ],
238
270
  }),
239
271
  ],
@@ -246,15 +278,55 @@ window.__ModuleLoader__.load({
246
278
 
247
279
  /** Polls the host route; the snapshot only changes when a request happened. */
248
280
  class UsageController {
249
- /** @param directories - `ctx.modelDirectories`, or undefined off Web. */
250
- constructor(directories) {
251
- this.directories = directories;
281
+ /** @param ctx - the client root context; `modelDirectories` is read from
282
+ * it per session rather than captured once, because whether the call
283
+ * succeeds depends on when it happens (see `directoryFor`). */
284
+ constructor(ctx) {
285
+ this.ctx = ctx;
252
286
  this.state = { open: false, providers: [], diag: undefined };
253
- this.store = runtime.createSnapshotStore({ ...this.state });
287
+ this.store = clientStore.createSnapshotStore({ ...this.state });
288
+ /**
289
+ * One standing selection store per session, mirroring the host
290
+ * directory's `current` once we manage to reach it. The hook has to
291
+ * name a store at registration time, but the directory is not reachable
292
+ * that early — so the badge subscribes to this instead, and `bind`
293
+ * fills it in later. Sessions whose directory never resolves keep the
294
+ * `current: null` seed and fall back to the every-route view.
295
+ */
296
+ this.selections = new Map();
297
+ this.unbind = new Map();
254
298
  this.timer = undefined;
255
299
  this.started = false;
256
300
  }
257
301
 
302
+ /** The standing mirror for one session, created on first request. */
303
+ selectionFor(sessionId) {
304
+ const key = String(sessionId);
305
+ const existing = this.selections.get(key);
306
+ if (existing !== undefined) return existing;
307
+ const mirror = clientStore.createSnapshotStore({ current: null });
308
+ this.selections.set(key, mirror);
309
+ return mirror;
310
+ }
311
+
312
+ /**
313
+ * Mirror the host's selection into this session's store, once.
314
+ *
315
+ * Idempotent and safe to retry: until the directory exists this is a no-op,
316
+ * so the caller can simply keep asking.
317
+ */
318
+ bind(sessionId) {
319
+ const key = String(sessionId);
320
+ if (this.unbind.has(key)) return;
321
+ const directory = this.directoryFor(sessionId);
322
+ if (directory === undefined) return;
323
+ const mirror = this.selectionFor(sessionId);
324
+ const copy = () => mirror.set({ current: directory.store.getSnapshot().current ?? null });
325
+ copy();
326
+ this.unbind.set(key, directory.store.subscribe(copy));
327
+ if (directory.store.getSnapshot().current === null) directory.load().catch(() => {});
328
+ }
329
+
258
330
  publish(patch) {
259
331
  this.state = { ...this.state, ...patch };
260
332
  this.store.set({ ...this.state });
@@ -272,45 +344,58 @@ window.__ModuleLoader__.load({
272
344
  }
273
345
 
274
346
  /**
275
- * The session's shared model-selection store, or undefined when the host
276
- * composed no model selection or does not know this session. Both cases
277
- * degrade to the every-route view rather than failing the registration.
347
+ * The session's shared model-selection directory, or undefined when it is
348
+ * not reachable from here.
349
+ *
350
+ * Two ways it is not: the host composed no model selection at all, and —
351
+ * the case that actually bites — nobody has built this session's directory
352
+ * yet. Building one reads `remote.session` and `sessions` through the
353
+ * *caller's* context, and this plugin deliberately injects neither, so
354
+ * cordis refuses with "cannot get property \"remote.session\" without
355
+ * inject". Once the composer's own model seat has built it, the resolver
356
+ * answers from its cache and the same call succeeds. So this is not an
357
+ * error to report, it is a "not yet" — hence `bind`'s retry.
278
358
  */
279
359
  directoryFor(sessionId) {
280
- if (this.directories === undefined || sessionId === undefined) return undefined;
360
+ if (sessionId === undefined) return undefined;
361
+ const directories = this.ctx.get("modelDirectories");
362
+ if (directories === undefined) return undefined;
281
363
  try {
282
- return this.directories.directoryFor(sessionId);
283
- } catch (_unknownSession) {
364
+ return directories.directoryFor(sessionId);
365
+ } catch (_notReachableYet) {
284
366
  return undefined;
285
367
  }
286
368
  }
287
369
 
288
370
  inject(sessionId) {
289
- const directory = this.directoryFor(sessionId);
290
371
  return {
291
- hooks: directory === undefined
292
- ? { localTokenUsage: this.store }
293
- : { localTokenUsage: this.store, modelSelection: directory.store },
294
- /**
295
- * `current` is null until something loads the directory. The composer
296
- * model seat normally does, but ask once so a fresh session shows the
297
- * right route instead of the union view.
298
- */
299
- ensureSelection: () => {
300
- if (directory === undefined) return;
301
- if (directory.store.getSnapshot().current !== null) return;
302
- directory.load().catch(() => {});
372
+ hooks: {
373
+ localTokenUsage: this.store,
374
+ modelSelection: this.selectionFor(sessionId),
303
375
  },
376
+ /** Mount-time attempt; `start`'s tick covers a mount that lands first. */
377
+ ensureSelection: () => this.bind(sessionId),
304
378
  start: () => {
379
+ this.bind(sessionId);
305
380
  if (this.started) return;
306
381
  this.started = true;
307
382
  this.poll();
308
- this.timer = setInterval(() => this.poll(), POLL_MS);
383
+ this.timer = setInterval(() => {
384
+ this.poll();
385
+ this.bind(sessionId);
386
+ }, POLL_MS);
309
387
  },
310
388
  stop: () => {
311
389
  if (this.timer !== undefined) clearInterval(this.timer);
312
390
  this.timer = undefined;
313
391
  this.started = false;
392
+ // Drop the mirror subscription with the badge; `bind` re-establishes
393
+ // it on the next mount, and the mirror keeps its last value meanwhile.
394
+ const stopMirror = this.unbind.get(String(sessionId));
395
+ if (stopMirror !== undefined) {
396
+ stopMirror();
397
+ this.unbind.delete(String(sessionId));
398
+ }
314
399
  },
315
400
  toggle: () => {
316
401
  const open = !this.state.open;
@@ -328,7 +413,7 @@ window.__ModuleLoader__.load({
328
413
  // Optional on purpose: read the service rather than declaring it in
329
414
  // `inject`, so a composition without model selection still loads this
330
415
  // plugin (the badge then reports every route, as it always did).
331
- const controller = new UsageController(ctx.get("modelDirectories"));
416
+ const controller = new UsageController(ctx);
332
417
  // The quota badge is a clickable control, so it belongs in the input
333
418
  // tool row (a list slot), beside the send button.
334
419
  ctx.slots.inject("conversation.input.right", () => ctx.slots.register({
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
@@ -13,14 +13,20 @@ import { LlmError } from "@deepseek-ai/dsh-llm";
13
13
  import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
14
14
  import { anthropicProvider } from "@earendil-works/pi-ai/providers/anthropic";
15
15
  import { openaiCodexProvider } from "@earendil-works/pi-ai/providers/openai-codex";
16
- import { defaultCodexAuthPath, resolveCodexAccessToken } from "./token-store.js";
16
+ import { defaultCodexAuthPath, readCodexAuth, resolveCodexAccessToken } from "./token-store.js";
17
17
  import { defaultClaudeAuthPath, resolveClaudeAccessToken } from "./claude-keychain.js";
18
- import { withUsageProbe } from "./usage.js";
18
+ import { anthropicUsage, codexUsage, withUsageProbe } from "./usage.js";
19
+ import { probeAnthropicQuota, probeCodexQuota } from "./probe.js";
20
+ import { GLM_ALIASES, GLM_PROVIDER, glmBaseDomain, glmUsage, probeGlmQuota, resolveGlmToken } from "./glm.js";
19
21
 
20
22
  /** Plugin identity used by the cordis loader entry. */
21
23
  export const name = "llm-local-token";
22
- /** Register only after the llm service exists. */
23
- export const inject = ["llm"];
24
+ /**
25
+ * Register only after the llm service exists. `timer` joins it because cordis
26
+ * throws on reading ctx.setTimeout/setInterval unless the service is declared
27
+ * here; the base profile mounts cordis-plugin-timer.
28
+ */
29
+ export const inject = ["llm", "timer"];
24
30
  /** Route prefix serving the quota snapshots to the browser. */
25
31
  const USAGE_PREFIX = "/llm-local-token";
26
32
 
@@ -61,6 +67,20 @@ function withApiKeyAuth(provider, name) {
61
67
  /** Transport values pi-ai's profile vocabulary accepts. */
62
68
  const TRANSPORTS = ["sse", "websocket", "websocket-cached", "auto"];
63
69
 
70
+ /**
71
+ * The route-owned image request policy. pi-ai fills these from its own defaults
72
+ * only for config-declared providers; a `profiles` callback like ours is read
73
+ * verbatim, so an omitted field arrives as undefined and
74
+ * `dsh-attachment-local` rejects the request with "Image request maxPixels must
75
+ * be a positive integer" the moment any history entry carries an image. The
76
+ * values below are pi-ai's own defaults.
77
+ */
78
+ const IMAGE_POLICY = {
79
+ maxRequestImageBytes: 20_971_520,
80
+ requestImagePixelBudget: 4_194_304,
81
+ requestImageMaxBytes: 1_048_576,
82
+ };
83
+
64
84
  /**
65
85
  * Build one adapter profile in the shape PiAiAdapter expects.
66
86
  * @param transport - optional streaming transport preference; undefined leaves
@@ -74,6 +94,7 @@ function profileOf(provider, displayName, piProvider, transport) {
74
94
  retryPolicy: undefined,
75
95
  streamIdleTimeoutMs: 300_000,
76
96
  configuredMaxTokens: new Map(),
97
+ ...IMAGE_POLICY,
77
98
  ...(transport === undefined ? {} : { transport }),
78
99
  };
79
100
  }
@@ -84,6 +105,14 @@ function profileOf(provider, displayName, piProvider, transport) {
84
105
  */
85
106
  export async function apply(ctx, config = {}) {
86
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 = [];
87
116
  /** Latest quota snapshot per provider id, replaced on every observed response. */
88
117
  const usage = new Map();
89
118
  /**
@@ -112,12 +141,27 @@ export async function apply(ctx, config = {}) {
112
141
  if (codexTransport !== requestedTransport) {
113
142
  ctx.logger.info(`llm-local-token: ignoring unknown codexTransport "${requestedTransport}"; using "sse"`);
114
143
  }
144
+ /** The Codex endpoint wants the account id beside the token; absent is fine. */
145
+ const codexAccountId = async () => {
146
+ try {
147
+ const auth = await readCodexAuth(codexAuthPath);
148
+ return auth?.tokens?.account_id ?? auth?.account_id;
149
+ } catch {
150
+ return undefined;
151
+ }
152
+ };
115
153
  routes.push({
116
154
  provider: "openai-codex",
117
155
  displayName: "OpenAI Codex (local token)",
118
156
  piProvider: withUsageProbe(withApiKeyAuth(openaiCodexProvider(), "Codex local token"), record),
119
157
  resolveApiKey: async () => resolveCodexAccessToken(codexAuthPath),
120
158
  transport: codexTransport,
159
+ readQuota: codexUsage,
160
+ probe: async () => probeCodexQuota({
161
+ accessToken: await resolveCodexAccessToken(codexAuthPath),
162
+ accountId: await codexAccountId(),
163
+ model: config.usageProbeCodexModel,
164
+ }),
121
165
  });
122
166
 
123
167
  // ── Claude route: local Claude Code credentials (legacy file or Keychain) ─
@@ -132,12 +176,40 @@ export async function apply(ctx, config = {}) {
132
176
  displayName: "Claude (local token)",
133
177
  piProvider: withUsageProbe(anthropicProvider(), record),
134
178
  resolveApiKey: async () => resolveClaudeAccessToken({ filePath: claudeAuthPath, service: claudeKeychainService, account: config.claudeKeychainAccount }),
179
+ readQuota: anthropicUsage,
180
+ probe: async () => probeAnthropicQuota({
181
+ accessToken: await resolveClaudeAccessToken({ filePath: claudeAuthPath, service: claudeKeychainService, account: config.claudeKeychainAccount }),
182
+ model: config.usageProbeAnthropicModel,
183
+ }),
135
184
  });
136
185
  } catch (error) {
137
186
  if (config.requireClaude === true) throw error;
138
187
  ctx.logger.info(`llm-local-token: Claude local token not usable (${String(error?.message ?? error).slice(0, 160)}); skipping Claude provider`);
139
188
  }
140
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
+
141
213
  const profiles = () => new Map(routes.map((route) => [
142
214
  route.provider,
143
215
  profileOf(route.provider, route.displayName, route.piProvider, route.transport),
@@ -161,6 +233,9 @@ export async function apply(ctx, config = {}) {
161
233
 
162
234
  ctx.llm.registerAdapter(routes.map((route) => route.provider), adapter);
163
235
 
236
+ /** Everything the panel speaks about: routes we serve, plus quota-only entries. */
237
+ const reported = [...routes, ...quotaOnly];
238
+
164
239
  // Quota snapshots observed on real responses, newest per provider.
165
240
  ctx.inject(["webServer"], (wctx) => {
166
241
  wctx.effect(() => wctx.webServer.register({
@@ -172,8 +247,9 @@ export async function apply(ctx, config = {}) {
172
247
  return sendJson(res, 404, { error: `unknown route "${path}"` });
173
248
  }
174
249
  return sendJson(res, 200, {
175
- providers: routes.map((route) => ({
250
+ providers: reported.map((route) => ({
176
251
  provider: route.provider,
252
+ ...(route.aliases === undefined ? {} : { aliases: route.aliases }),
177
253
  displayName: route.displayName,
178
254
  usage: usage.get(route.provider) ?? null,
179
255
  })),
@@ -184,5 +260,74 @@ export async function apply(ctx, config = {}) {
184
260
  }), "llm-local-token: usage route");
185
261
  wctx.logger.info(`llm-local-token: usage route mounted at ${USAGE_PREFIX}/usage`);
186
262
  });
187
- ctx.logger.info(`llm-local-token: registered ${routes.map((route) => route.provider).join(", ")} (codex auth: ${codexAuthPath}, codex transport: ${codexTransport})`);
263
+ // ── Scheduled quota refresh ───────────────────────────────────────────────
264
+ //
265
+ // usage.js only reports what a real request happened to return, so the route
266
+ // you are not using reads "no data yet" indefinitely. These probes close that
267
+ // gap without polling the model: one bare minimal request per provider (see
268
+ // probe.js), on a schedule, carrying no prompt, skills, tools or history.
269
+ //
270
+ // A probe is a convenience and never a dependency: each failure is logged and
271
+ // swallowed, leaving the panel with whatever snapshot it already had.
272
+ const probeOnce = async (reason) => {
273
+ for (const route of reported) {
274
+ if (typeof route.probe !== "function") continue;
275
+ try {
276
+ const snapshot = route.readQuota(await route.probe());
277
+ if (snapshot === undefined) {
278
+ ctx.logger.info(`llm-local-token: ${route.provider} probe carried no quota data`);
279
+ continue;
280
+ }
281
+ record(snapshot);
282
+ ctx.logger.info(`llm-local-token: refreshed ${route.provider} quota (${reason})`);
283
+ } catch (error) {
284
+ ctx.logger.info(`llm-local-token: ${route.provider} quota probe failed (${String(error?.message ?? error).slice(0, 160)})`);
285
+ }
286
+ }
287
+ };
288
+
289
+ // cordis's timer service disposes these with the plugin; the globals are only
290
+ // a fallback for a profile that did not load it.
291
+ const stops = [];
292
+ const after = (ms, fn) => {
293
+ if (typeof ctx.setTimeout === "function") return void ctx.setTimeout(fn, ms);
294
+ const handle = globalThis.setTimeout(fn, ms);
295
+ stops.push(() => globalThis.clearTimeout(handle));
296
+ };
297
+ const every = (ms, fn) => {
298
+ if (typeof ctx.setInterval === "function") return void ctx.setInterval(fn, ms);
299
+ const handle = globalThis.setInterval(fn, ms);
300
+ stops.push(() => globalThis.clearInterval(handle));
301
+ };
302
+ ctx.on("dispose", () => {
303
+ for (const stop of stops) stop();
304
+ });
305
+
306
+ if (config.usageProbe !== false) {
307
+ const atHour = Number(config.usageProbeAtHour);
308
+ const daily = Number.isInteger(atHour) && atHour >= 0 && atHour <= 23;
309
+
310
+ // A wall-clock schedule only fires while dsh happens to be running, and a
311
+ // desktop session is usually closed at 03:00 — so boot is its own trigger.
312
+ after(Number(config.usageProbeStartupDelayMs ?? 20000), () => void probeOnce("startup"));
313
+
314
+ if (daily) {
315
+ const next = new Date();
316
+ next.setHours(atHour, 0, 0, 0);
317
+ if (next.getTime() <= Date.now()) next.setDate(next.getDate() + 1);
318
+ after(next.getTime() - Date.now(), () => {
319
+ void probeOnce("daily");
320
+ every(86400000, () => void probeOnce("daily"));
321
+ });
322
+ ctx.logger.info(`llm-local-token: quota probe scheduled daily at ${String(atHour).padStart(2, "0")}:00 local`);
323
+ } else {
324
+ const requested = Number(config.usageProbeIntervalHours ?? 4);
325
+ const hours = Number.isFinite(requested) && requested > 0 ? requested : 4;
326
+ every(hours * 3600000, () => void probeOnce("interval"));
327
+ ctx.logger.info(`llm-local-token: quota probe scheduled every ${hours}h`);
328
+ }
329
+ }
330
+
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})`);
188
333
  }
package/lib/probe.js ADDED
@@ -0,0 +1,84 @@
1
+ // Bare quota probes.
2
+ //
3
+ // A snapshot only changes when a real request reports it (see usage.js), so a
4
+ // route you never call shows nothing at all. These probes close that gap with
5
+ // the smallest request each provider accepts: no system prompt, no skills, no
6
+ // MCP tools, no conversation history, and nothing stored server-side. The reply
7
+ // is discarded — the rate-limit headers are the only thing read back.
8
+ //
9
+ // Measured cost per probe: Codex 16 input tokens, Anthropic 8 input + 1 output.
10
+
11
+ import { normalizeHeaders } from "./usage.js";
12
+
13
+ const CODEX_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses";
14
+ const ANTHROPIC_ENDPOINT = "https://api.anthropic.com/v1/messages";
15
+
16
+ /** Models are only a vehicle for the headers; both are overridable in config. */
17
+ const CODEX_MODEL = "gpt-5.6-terra";
18
+ const ANTHROPIC_MODEL = "claude-haiku-4-5-20251001";
19
+
20
+ /**
21
+ * Take the headers and drop the body: the answer is never used, and cancelling
22
+ * early stops the stream instead of paying for tokens nobody reads.
23
+ */
24
+ async function headersOnly(response) {
25
+ try {
26
+ await response.body?.cancel();
27
+ } catch {
28
+ // Already settled or uncancellable; the headers are in hand either way.
29
+ }
30
+ return normalizeHeaders(response.headers);
31
+ }
32
+
33
+ /**
34
+ * Ask ChatGPT's Codex endpoint for one period, purely to read `x-codex-*` back.
35
+ *
36
+ * Three constraints are load-bearing and were each found by being rejected:
37
+ * the endpoint answers only over SSE (the quota headers do not exist on the
38
+ * WebSocket transport), it rejects `max_output_tokens` outright with HTTP 400,
39
+ * and `store: false` is what keeps the probe out of the account's history.
40
+ * @returns normalized response headers.
41
+ */
42
+ export async function probeCodexQuota({ accessToken, accountId, model = CODEX_MODEL }) {
43
+ const response = await fetch(CODEX_ENDPOINT, {
44
+ method: "POST",
45
+ headers: {
46
+ authorization: `Bearer ${accessToken}`,
47
+ "content-type": "application/json",
48
+ accept: "text/event-stream",
49
+ "openai-beta": "responses=experimental",
50
+ originator: "codex_cli_rs",
51
+ ...(accountId === undefined || accountId === null ? {} : { "chatgpt-account-id": String(accountId) }),
52
+ },
53
+ body: JSON.stringify({
54
+ model,
55
+ instructions: "Reply with a single period.",
56
+ input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "." }] }],
57
+ stream: true,
58
+ store: false,
59
+ }),
60
+ });
61
+ return headersOnly(response);
62
+ }
63
+
64
+ /**
65
+ * Ask Anthropic for one token, purely to read `anthropic-ratelimit-unified-*`.
66
+ *
67
+ * The free `/v1/messages/count_tokens` endpoint cannot serve this: it answers
68
+ * HTTP 200 and carries no rate-limit headers at all, so a real (if minimal)
69
+ * message is the only way to learn the numbers.
70
+ * @returns normalized response headers.
71
+ */
72
+ export async function probeAnthropicQuota({ accessToken, model = ANTHROPIC_MODEL }) {
73
+ const response = await fetch(ANTHROPIC_ENDPOINT, {
74
+ method: "POST",
75
+ headers: {
76
+ authorization: `Bearer ${accessToken}`,
77
+ "content-type": "application/json",
78
+ "anthropic-version": "2023-06-01",
79
+ "anthropic-beta": "oauth-2025-04-20",
80
+ },
81
+ body: JSON.stringify({ model, max_tokens: 1, messages: [{ role: "user", content: "." }] }),
82
+ });
83
+ return headersOnly(response);
84
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-llm-local-token",
3
- "version": "1.3.2",
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",
@@ -23,6 +26,7 @@
23
26
  },
24
27
  "files": [
25
28
  "lib/",
29
+ "!lib/*.bak*",
26
30
  "cordis.patch.yml",
27
31
  "README.md",
28
32
  "README.zh.md",