dsh-llm-local-token 1.3.2 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -78,20 +78,29 @@ All keys are optional; the defaults match a stock CLI install.
78
78
  | `claudeKeychainService` | `Claude Code-credentials` | macOS Keychain service holding the Claude OAuth payload |
79
79
  | `requireClaude` | `false` | Fail activation when no Claude credential is found, instead of skipping the route |
80
80
  | `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. |
81
+ | `usageProbe` | `true` | Refresh quota on a schedule with one bare minimal request per provider. Set `false` to keep the panel purely passive. |
82
+ | `usageProbeIntervalHours` | `4` | Hours between probes. Tracks the 5-hour window, which resets about five times a day; `24` is once per day. |
83
+ | `usageProbeAtHour` | — | Local hour `0`–`23` for a once-daily probe at a fixed clock time. Overrides `usageProbeIntervalHours`. |
84
+ | `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
+ | `usageProbeCodexModel` | `gpt-5.6-terra` | Model the Codex probe names; only a vehicle for the headers. |
86
+ | `usageProbeAnthropicModel` | `claude-haiku-4-5-20251001` | Model the Anthropic probe names; only a vehicle for the headers. |
81
87
 
82
88
  ## Subscription usage badge
83
89
 
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.
90
+ Both providers state their quota in response headers, so reading it off a real request costs
91
+ nothing. A route you never call has nothing to report, though — so the plugin also refreshes on a
92
+ schedule, with one deliberately tiny request per provider (16 input tokens for Codex, 9 for
93
+ Anthropic) that carries no prompt, skills, tools or history and is never stored. A badge appears in
94
+ the composer bar next to the context ring; click it for the breakdown.
87
95
 
88
96
  | Provider | Headers read | Shown |
89
97
  | --- | --- | --- |
90
98
  | `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
99
  | `anthropic` | `anthropic-ratelimit-unified-{5h,7d}-{utilization,reset,status}` | used % for the 5-hour and 7-day windows, reset countdown |
92
100
 
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.
101
+ The badge is green under 60%, amber under 85%, red above. Any reading older than a minute carries
102
+ its age, because a 5-hour window resets about five times a day and a stale number that looks live
103
+ is worse than none.
95
104
  The browser half polls `GET /llm-local-token/usage` every 15s; that route only reads the
96
105
  in-memory snapshot.
97
106
 
package/README.zh.md CHANGED
@@ -73,19 +73,27 @@ agent-default-model:
73
73
  | `claudeKeychainService` | `Claude Code-credentials` | 存放 Claude OAuth 数据的 Keychain 服务名 |
74
74
  | `requireClaude` | `false` | 为 `true` 时找不到 Claude 凭据就启动失败(而不是跳过) |
75
75
  | `codexTransport` | `"sse"` | Codex 路由的流式通道:`sse` / `websocket` / `websocket-cached` / `auto`。**额度徽标依赖 `sse`**:pi-ai 默认的 `auto` 会走 WebSocket,而 `x-codex-*` 额度响应头只存在于 SSE 响应上,走 WS 时徽标永远是「暂无数据」。想要 WebSocket 就设成 `auto`,代价是没有 Codex 额度数据。 |
76
+ | `usageProbe` | `true` | 是否定时刷新额度(每个 provider 一个最小裸请求)。设 `false` 则完全被动,只读真实请求。 |
77
+ | `usageProbeIntervalHours` | `4` | 探测间隔小时数。对齐 5 小时窗口(每天重置约五次);设 `24` 就是每天一次。 |
78
+ | `usageProbeAtHour` | — | 本地时钟小时 `0`–`23`,在固定时间每天探测一次。设置后覆盖 `usageProbeIntervalHours`。 |
79
+ | `usageProbeStartupDelayMs` | `20000` | 启动探测的延迟。固定时间点只在 dsh 恰好运行时才触发,所以启动本身也是一个触发点。 |
80
+ | `usageProbeCodexModel` | `gpt-5.6-terra` | Codex 探测使用的模型,仅作为拿响应头的载体。 |
81
+ | `usageProbeAnthropicModel` | `claude-haiku-4-5-20251001` | Anthropic 探测使用的模型,仅作为拿响应头的载体。 |
76
82
 
77
83
  ## 订阅用量徽标
78
84
 
79
- 两家 provider 都在响应头里返回额度状态,插件顺带读取即可 —— 不轮询、不额外调接口。输入框工具条上
80
- (上下文圆环旁边)会出现一个徽标,点开看明细。
85
+ 两家 provider 都在响应头里返回额度状态,所以真实请求顺带就能读到。但你从没调用过的那条路由无从上报 ——
86
+ 因此插件还会**定时刷新**:每个 provider 发一个刻意做到最小的请求(Codex 16 个输入 token、Anthropic 9 个),
87
+ 不带 prompt、skill、工具与历史,也不落存储。输入框工具条上(上下文圆环旁边)会出现一个徽标,点开看明细。
81
88
 
82
89
  | Provider | 读取的响应头 | 展示内容 |
83
90
  | --- | --- | --- |
84
91
  | `openai-codex` | `x-codex-primary-*`、`x-codex-secondary-*`、`x-codex-plan-type`、`x-codex-credits-balance` | 套餐、各窗口已用百分比、重置倒计时、点数余额 |
85
92
  | `anthropic` | `anthropic-ratelimit-unified-{5h,7d}-{utilization,reset,status}` | 5 小时与 7 天窗口的已用百分比、重置倒计时 |
86
93
 
87
- 低于 60% 显示绿色,低于 85% 琥珀色,更高显示红色。数值来自**最近一次真实请求**,所以刚启动时会显示
88
- 「暂无数据」,发一条消息即可。浏览器端每 15 秒轮询 `GET /llm-local-token/usage`,该路由只读内存快照。
94
+ 低于 60% 显示绿色,低于 85% 琥珀色,更高显示红色。超过一分钟的数值会标注**读取时间** —— 5 小时窗口每天
95
+ 重置约五次,一个看起来实时的过期数字比没有数字更糟。浏览器端每 15 秒轮询 `GET /llm-local-token/usage`,
96
+ 该路由只读内存快照。
89
97
 
90
98
  徽标**只显示当前选中模型所属 provider** 的用量:选 Codex 就是 Codex 的窗口,切到 Claude 就换成
91
99
  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
 
@@ -52,6 +52,7 @@ window.__ModuleLoader__.load({
52
52
  resets: "resets {when}", credits: "credits: {balance}",
53
53
  current: "current",
54
54
  "unit.d": "{n} days", "unit.h": "{n} hours", "unit.m": "{n} min",
55
+ readAt: "read {when} ago",
55
56
  };
56
57
  const zh = {
57
58
  label: "额度", title: "订阅用量",
@@ -61,6 +62,7 @@ window.__ModuleLoader__.load({
61
62
  resets: "{when}重置", credits: "点数余额:{balance}",
62
63
  current: "当前",
63
64
  "unit.d": "{n} 天", "unit.h": "{n} 小时", "unit.m": "{n} 分钟",
65
+ readAt: "{when}前读取",
64
66
  };
65
67
 
66
68
  /** Green under 60%, amber under 85%, red above. */
@@ -98,6 +100,24 @@ window.__ModuleLoader__.load({
98
100
  return String(Math.round(seconds / 86400)) + "d";
99
101
  }
100
102
 
103
+ /**
104
+ * Compact "how long ago" for a snapshot instant; blank under a minute.
105
+ *
106
+ * A scheduled probe means a number can be hours old while still looking
107
+ * live, and a 5-hour window resets about five times a day — so the age is
108
+ * load-bearing, not decoration. Returning "" for a fresh read keeps the
109
+ * panel quiet until staleness is the thing worth saying.
110
+ */
111
+ function agoOf(iso) {
112
+ const at = Date.parse(iso ?? "");
113
+ if (!Number.isFinite(at)) return "";
114
+ const seconds = Math.max(0, Math.round((Date.now() - at) / 1000));
115
+ if (seconds < 60) return "";
116
+ if (seconds < 3600) return String(Math.round(seconds / 60)) + "m";
117
+ if (seconds < 86400) return String(Math.round(seconds / 3600)) + "h";
118
+ return String(Math.round(seconds / 86400)) + "d";
119
+ }
120
+
101
121
  function UsageRow({ t, entry }) {
102
122
  const percent = Math.round(entry.used * 100);
103
123
  return jsxs("div", {
@@ -123,12 +143,10 @@ window.__ModuleLoader__.load({
123
143
  function UsageBadge(props) {
124
144
  const { t } = props;
125
145
  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;
146
+ // The provider of the model this session will actually use. The controller
147
+ // always supplies this hook backed by a standing empty selection when the
148
+ // host composed no model selection — so the call is unconditional.
149
+ const activeProvider = props.useModelSelection((snapshot) => snapshot.current?.provider ?? null);
132
150
  const wrapRef = react.useRef(null);
133
151
  const [pos, setPos] = react.useState(null);
134
152
  react.useEffect(() => {
@@ -234,6 +252,9 @@ window.__ModuleLoader__.load({
234
252
  entry.usage.credits === undefined || entry.usage.creditsUnlimited === true
235
253
  ? null
236
254
  : jsx("p", { className: "ltk_hint", children: t("credits", { balance: String(entry.usage.credits) }) }),
255
+ agoOf(entry.usage.at).length === 0
256
+ ? null
257
+ : jsx("p", { className: "ltk_hint", children: t("readAt", { when: agoOf(entry.usage.at) }) }),
237
258
  ],
238
259
  }),
239
260
  ],
@@ -246,15 +267,55 @@ window.__ModuleLoader__.load({
246
267
 
247
268
  /** Polls the host route; the snapshot only changes when a request happened. */
248
269
  class UsageController {
249
- /** @param directories - `ctx.modelDirectories`, or undefined off Web. */
250
- constructor(directories) {
251
- this.directories = directories;
270
+ /** @param ctx - the client root context; `modelDirectories` is read from
271
+ * it per session rather than captured once, because whether the call
272
+ * succeeds depends on when it happens (see `directoryFor`). */
273
+ constructor(ctx) {
274
+ this.ctx = ctx;
252
275
  this.state = { open: false, providers: [], diag: undefined };
253
- this.store = runtime.createSnapshotStore({ ...this.state });
276
+ this.store = clientStore.createSnapshotStore({ ...this.state });
277
+ /**
278
+ * One standing selection store per session, mirroring the host
279
+ * directory's `current` once we manage to reach it. The hook has to
280
+ * name a store at registration time, but the directory is not reachable
281
+ * that early — so the badge subscribes to this instead, and `bind`
282
+ * fills it in later. Sessions whose directory never resolves keep the
283
+ * `current: null` seed and fall back to the every-route view.
284
+ */
285
+ this.selections = new Map();
286
+ this.unbind = new Map();
254
287
  this.timer = undefined;
255
288
  this.started = false;
256
289
  }
257
290
 
291
+ /** The standing mirror for one session, created on first request. */
292
+ selectionFor(sessionId) {
293
+ const key = String(sessionId);
294
+ const existing = this.selections.get(key);
295
+ if (existing !== undefined) return existing;
296
+ const mirror = clientStore.createSnapshotStore({ current: null });
297
+ this.selections.set(key, mirror);
298
+ return mirror;
299
+ }
300
+
301
+ /**
302
+ * Mirror the host's selection into this session's store, once.
303
+ *
304
+ * Idempotent and safe to retry: until the directory exists this is a no-op,
305
+ * so the caller can simply keep asking.
306
+ */
307
+ bind(sessionId) {
308
+ const key = String(sessionId);
309
+ if (this.unbind.has(key)) return;
310
+ const directory = this.directoryFor(sessionId);
311
+ if (directory === undefined) return;
312
+ const mirror = this.selectionFor(sessionId);
313
+ const copy = () => mirror.set({ current: directory.store.getSnapshot().current ?? null });
314
+ copy();
315
+ this.unbind.set(key, directory.store.subscribe(copy));
316
+ if (directory.store.getSnapshot().current === null) directory.load().catch(() => {});
317
+ }
318
+
258
319
  publish(patch) {
259
320
  this.state = { ...this.state, ...patch };
260
321
  this.store.set({ ...this.state });
@@ -272,45 +333,58 @@ window.__ModuleLoader__.load({
272
333
  }
273
334
 
274
335
  /**
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.
336
+ * The session's shared model-selection directory, or undefined when it is
337
+ * not reachable from here.
338
+ *
339
+ * Two ways it is not: the host composed no model selection at all, and —
340
+ * the case that actually bites — nobody has built this session's directory
341
+ * yet. Building one reads `remote.session` and `sessions` through the
342
+ * *caller's* context, and this plugin deliberately injects neither, so
343
+ * cordis refuses with "cannot get property \"remote.session\" without
344
+ * inject". Once the composer's own model seat has built it, the resolver
345
+ * answers from its cache and the same call succeeds. So this is not an
346
+ * error to report, it is a "not yet" — hence `bind`'s retry.
278
347
  */
279
348
  directoryFor(sessionId) {
280
- if (this.directories === undefined || sessionId === undefined) return undefined;
349
+ if (sessionId === undefined) return undefined;
350
+ const directories = this.ctx.get("modelDirectories");
351
+ if (directories === undefined) return undefined;
281
352
  try {
282
- return this.directories.directoryFor(sessionId);
283
- } catch (_unknownSession) {
353
+ return directories.directoryFor(sessionId);
354
+ } catch (_notReachableYet) {
284
355
  return undefined;
285
356
  }
286
357
  }
287
358
 
288
359
  inject(sessionId) {
289
- const directory = this.directoryFor(sessionId);
290
360
  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(() => {});
361
+ hooks: {
362
+ localTokenUsage: this.store,
363
+ modelSelection: this.selectionFor(sessionId),
303
364
  },
365
+ /** Mount-time attempt; `start`'s tick covers a mount that lands first. */
366
+ ensureSelection: () => this.bind(sessionId),
304
367
  start: () => {
368
+ this.bind(sessionId);
305
369
  if (this.started) return;
306
370
  this.started = true;
307
371
  this.poll();
308
- this.timer = setInterval(() => this.poll(), POLL_MS);
372
+ this.timer = setInterval(() => {
373
+ this.poll();
374
+ this.bind(sessionId);
375
+ }, POLL_MS);
309
376
  },
310
377
  stop: () => {
311
378
  if (this.timer !== undefined) clearInterval(this.timer);
312
379
  this.timer = undefined;
313
380
  this.started = false;
381
+ // Drop the mirror subscription with the badge; `bind` re-establishes
382
+ // it on the next mount, and the mirror keeps its last value meanwhile.
383
+ const stopMirror = this.unbind.get(String(sessionId));
384
+ if (stopMirror !== undefined) {
385
+ stopMirror();
386
+ this.unbind.delete(String(sessionId));
387
+ }
314
388
  },
315
389
  toggle: () => {
316
390
  const open = !this.state.open;
@@ -328,7 +402,7 @@ window.__ModuleLoader__.load({
328
402
  // Optional on purpose: read the service rather than declaring it in
329
403
  // `inject`, so a composition without model selection still loads this
330
404
  // plugin (the badge then reports every route, as it always did).
331
- const controller = new UsageController(ctx.get("modelDirectories"));
405
+ const controller = new UsageController(ctx);
332
406
  // The quota badge is a clickable control, so it belongs in the input
333
407
  // tool row (a list slot), beside the send button.
334
408
  ctx.slots.inject("conversation.input.right", () => ctx.slots.register({
package/lib/index.js CHANGED
@@ -13,14 +13,19 @@ 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";
19
20
 
20
21
  /** Plugin identity used by the cordis loader entry. */
21
22
  export const name = "llm-local-token";
22
- /** Register only after the llm service exists. */
23
- export const inject = ["llm"];
23
+ /**
24
+ * Register only after the llm service exists. `timer` joins it because cordis
25
+ * throws on reading ctx.setTimeout/setInterval unless the service is declared
26
+ * here; the base profile mounts cordis-plugin-timer.
27
+ */
28
+ export const inject = ["llm", "timer"];
24
29
  /** Route prefix serving the quota snapshots to the browser. */
25
30
  const USAGE_PREFIX = "/llm-local-token";
26
31
 
@@ -61,6 +66,20 @@ function withApiKeyAuth(provider, name) {
61
66
  /** Transport values pi-ai's profile vocabulary accepts. */
62
67
  const TRANSPORTS = ["sse", "websocket", "websocket-cached", "auto"];
63
68
 
69
+ /**
70
+ * The route-owned image request policy. pi-ai fills these from its own defaults
71
+ * only for config-declared providers; a `profiles` callback like ours is read
72
+ * verbatim, so an omitted field arrives as undefined and
73
+ * `dsh-attachment-local` rejects the request with "Image request maxPixels must
74
+ * be a positive integer" the moment any history entry carries an image. The
75
+ * values below are pi-ai's own defaults.
76
+ */
77
+ const IMAGE_POLICY = {
78
+ maxRequestImageBytes: 20_971_520,
79
+ requestImagePixelBudget: 4_194_304,
80
+ requestImageMaxBytes: 1_048_576,
81
+ };
82
+
64
83
  /**
65
84
  * Build one adapter profile in the shape PiAiAdapter expects.
66
85
  * @param transport - optional streaming transport preference; undefined leaves
@@ -74,6 +93,7 @@ function profileOf(provider, displayName, piProvider, transport) {
74
93
  retryPolicy: undefined,
75
94
  streamIdleTimeoutMs: 300_000,
76
95
  configuredMaxTokens: new Map(),
96
+ ...IMAGE_POLICY,
77
97
  ...(transport === undefined ? {} : { transport }),
78
98
  };
79
99
  }
@@ -112,12 +132,27 @@ export async function apply(ctx, config = {}) {
112
132
  if (codexTransport !== requestedTransport) {
113
133
  ctx.logger.info(`llm-local-token: ignoring unknown codexTransport "${requestedTransport}"; using "sse"`);
114
134
  }
135
+ /** The Codex endpoint wants the account id beside the token; absent is fine. */
136
+ const codexAccountId = async () => {
137
+ try {
138
+ const auth = await readCodexAuth(codexAuthPath);
139
+ return auth?.tokens?.account_id ?? auth?.account_id;
140
+ } catch {
141
+ return undefined;
142
+ }
143
+ };
115
144
  routes.push({
116
145
  provider: "openai-codex",
117
146
  displayName: "OpenAI Codex (local token)",
118
147
  piProvider: withUsageProbe(withApiKeyAuth(openaiCodexProvider(), "Codex local token"), record),
119
148
  resolveApiKey: async () => resolveCodexAccessToken(codexAuthPath),
120
149
  transport: codexTransport,
150
+ readQuota: codexUsage,
151
+ probe: async () => probeCodexQuota({
152
+ accessToken: await resolveCodexAccessToken(codexAuthPath),
153
+ accountId: await codexAccountId(),
154
+ model: config.usageProbeCodexModel,
155
+ }),
121
156
  });
122
157
 
123
158
  // ── Claude route: local Claude Code credentials (legacy file or Keychain) ─
@@ -132,6 +167,11 @@ export async function apply(ctx, config = {}) {
132
167
  displayName: "Claude (local token)",
133
168
  piProvider: withUsageProbe(anthropicProvider(), record),
134
169
  resolveApiKey: async () => resolveClaudeAccessToken({ filePath: claudeAuthPath, service: claudeKeychainService, account: config.claudeKeychainAccount }),
170
+ readQuota: anthropicUsage,
171
+ probe: async () => probeAnthropicQuota({
172
+ accessToken: await resolveClaudeAccessToken({ filePath: claudeAuthPath, service: claudeKeychainService, account: config.claudeKeychainAccount }),
173
+ model: config.usageProbeAnthropicModel,
174
+ }),
135
175
  });
136
176
  } catch (error) {
137
177
  if (config.requireClaude === true) throw error;
@@ -184,5 +224,73 @@ export async function apply(ctx, config = {}) {
184
224
  }), "llm-local-token: usage route");
185
225
  wctx.logger.info(`llm-local-token: usage route mounted at ${USAGE_PREFIX}/usage`);
186
226
  });
227
+ // ── Scheduled quota refresh ───────────────────────────────────────────────
228
+ //
229
+ // usage.js only reports what a real request happened to return, so the route
230
+ // you are not using reads "no data yet" indefinitely. These probes close that
231
+ // gap without polling the model: one bare minimal request per provider (see
232
+ // probe.js), on a schedule, carrying no prompt, skills, tools or history.
233
+ //
234
+ // A probe is a convenience and never a dependency: each failure is logged and
235
+ // swallowed, leaving the panel with whatever snapshot it already had.
236
+ const probeOnce = async (reason) => {
237
+ for (const route of routes) {
238
+ if (typeof route.probe !== "function") continue;
239
+ try {
240
+ const snapshot = route.readQuota(await route.probe());
241
+ if (snapshot === undefined) {
242
+ ctx.logger.info(`llm-local-token: ${route.provider} probe carried no quota headers`);
243
+ continue;
244
+ }
245
+ record(snapshot);
246
+ ctx.logger.info(`llm-local-token: refreshed ${route.provider} quota (${reason})`);
247
+ } catch (error) {
248
+ ctx.logger.info(`llm-local-token: ${route.provider} quota probe failed (${String(error?.message ?? error).slice(0, 160)})`);
249
+ }
250
+ }
251
+ };
252
+
253
+ // cordis's timer service disposes these with the plugin; the globals are only
254
+ // a fallback for a profile that did not load it.
255
+ const stops = [];
256
+ const after = (ms, fn) => {
257
+ if (typeof ctx.setTimeout === "function") return void ctx.setTimeout(fn, ms);
258
+ const handle = globalThis.setTimeout(fn, ms);
259
+ stops.push(() => globalThis.clearTimeout(handle));
260
+ };
261
+ const every = (ms, fn) => {
262
+ if (typeof ctx.setInterval === "function") return void ctx.setInterval(fn, ms);
263
+ const handle = globalThis.setInterval(fn, ms);
264
+ stops.push(() => globalThis.clearInterval(handle));
265
+ };
266
+ ctx.on("dispose", () => {
267
+ for (const stop of stops) stop();
268
+ });
269
+
270
+ if (config.usageProbe !== false) {
271
+ const atHour = Number(config.usageProbeAtHour);
272
+ const daily = Number.isInteger(atHour) && atHour >= 0 && atHour <= 23;
273
+
274
+ // A wall-clock schedule only fires while dsh happens to be running, and a
275
+ // desktop session is usually closed at 03:00 — so boot is its own trigger.
276
+ after(Number(config.usageProbeStartupDelayMs ?? 20000), () => void probeOnce("startup"));
277
+
278
+ if (daily) {
279
+ const next = new Date();
280
+ next.setHours(atHour, 0, 0, 0);
281
+ if (next.getTime() <= Date.now()) next.setDate(next.getDate() + 1);
282
+ after(next.getTime() - Date.now(), () => {
283
+ void probeOnce("daily");
284
+ every(86400000, () => void probeOnce("daily"));
285
+ });
286
+ ctx.logger.info(`llm-local-token: quota probe scheduled daily at ${String(atHour).padStart(2, "0")}:00 local`);
287
+ } else {
288
+ const requested = Number(config.usageProbeIntervalHours ?? 4);
289
+ const hours = Number.isFinite(requested) && requested > 0 ? requested : 4;
290
+ every(hours * 3600000, () => void probeOnce("interval"));
291
+ ctx.logger.info(`llm-local-token: quota probe scheduled every ${hours}h`);
292
+ }
293
+ }
294
+
187
295
  ctx.logger.info(`llm-local-token: registered ${routes.map((route) => route.provider).join(", ")} (codex auth: ${codexAuthPath}, codex transport: ${codexTransport})`);
188
296
  }
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,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-llm-local-token",
3
- "version": "1.3.2",
3
+ "version": "1.4.1",
4
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.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -23,6 +23,7 @@
23
23
  },
24
24
  "files": [
25
25
  "lib/",
26
+ "!lib/*.bak*",
26
27
  "cordis.patch.yml",
27
28
  "README.md",
28
29
  "README.zh.md",