@ychris12138/dsh-usage-stats 0.2.8 → 0.2.10

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
@@ -53,8 +53,8 @@ dsh plugin --profile web add "github:Ychris12138/dsh-usage-stats"
53
53
  升级或卸载:
54
54
 
55
55
  ```bash
56
- dsh plugin --profile web update dsh-usage-stats
57
- dsh plugin --profile web remove dsh-usage-stats
56
+ dsh plugin --profile web update "@ychris12138/dsh-usage-stats"
57
+ dsh plugin --profile web remove "@ychris12138/dsh-usage-stats"
58
58
  ```
59
59
 
60
60
  <details>
@@ -66,7 +66,7 @@ PowerShell、命令提示符和 macOS/Linux 终端使用同一条命令:
66
66
  npx --yes github:Ychris12138/dsh-usage-stats
67
67
  ```
68
68
 
69
- 安装器会把运行文件复制到 `~/.dsh/profiles/node_modules/dsh-usage-stats`,并在 `profiles/web/cordis.patch.yml` 中幂等启用插件。重复运行即可更新,不会重复追加配置。设置了 `DSH_HOME` 时使用该目录。
69
+ 安装器会把运行文件复制到 `~/.dsh/profiles/node_modules/@ychris12138/dsh-usage-stats`,并在 `profiles/web/cordis.patch.yml` 中以带引号的 scoped identity 幂等启用插件。重复运行即可更新,不会重复追加配置;旧版 `name: dsh-usage-stats` 和未加引号的 `name: @ychris12138/dsh-usage-stats` 会自动迁移。设置了 `DSH_HOME` 时使用该目录。
70
70
 
71
71
  `dsh plugin` 与 `npx` 是两条独立安装路径,请选择其中一种;不要同时保留手工 Cordis entry 和 bundle 注册,否则会重复挂载。
72
72
 
@@ -98,6 +98,7 @@ npx --yes github:Ychris12138/dsh-usage-stats --no-enable
98
98
  | Z.ai / 智谱 | 订阅 | `ZAI_API_KEY` | Coding Plan quota/subscription |
99
99
  | Kimi For Coding | 订阅 | `KIMI_API_KEY` | `/coding/v1/usages` |
100
100
  | MiniMax Coding Plan | 订阅 | `MINIMAX_API_KEY` | `/v1/token_plan/remains` |
101
+ | Ollama 云 | 订阅 | `OLLAMA_API_KEY` | `/api/usage`(5小时 + 周窗口) |
101
102
  | New API | 余额 | provider 推理 Token | `/api/usage/token/` |
102
103
  | Sub2API / Passion | 自动判别 | provider `apiKeyEnv` | `/v1/usage` |
103
104
  | Sub2API 面板(真实) | 余额 | provider 推理 Token | `/user/balance`(复用 apiKey) |
@@ -139,15 +140,33 @@ KIMI_API_KEY: your-kimi-key
139
140
  MINIMAX_API_KEY: your-minimax-key
140
141
  # 中国区 MiniMax 用户可选;默认 global
141
142
  MINIMAX_API_REGION: cn
143
+ OLLAMA_API_KEY: sk-ollama-your-key
142
144
  ```
143
145
 
144
146
  OpenCode Go 依次尝试 Harness credential、`~/.local/share/opencode/auth.json`,最后才使用显式 `OPENCODE_GO_AUTH_COOKIE + OPENCODE_GO_WORKSPACE_ID` 兼容回退。Bearer usage endpoint 目前不是公开 API,可能随上游变化;Cookie 等同登录凭据,不应进入日志或 issue。
145
147
 
148
+ Ollama 云读取 `OLLAMA_API_KEY`,调用 `/api/usage` 展示两个订阅窗口(5 小时会话 + 每周),无余额;`usage` 按 0..1 比例换算为进度条。
149
+
150
+ Ollama 适配器只对**已配置的 provider** 生效,不会自动添加账户:当 provider 的 id 为 `ollama`,或其 baseURL 主机为 `ollama.com`(含子域)时自动选用;本地 Ollama(`localhost:11434`)不会被当作云配额账户。特殊代理/自定义端点可用显式 monitor 绑定:
151
+
152
+ ```yaml
153
+ # ~/.dsh/profiles/web/cordis.patch.yml
154
+ - insert:
155
+ - id: usage-stats
156
+ name: dsh-usage-stats
157
+ config:
158
+ monitors:
159
+ relay-ollama: # 你配置的 provider id
160
+ adapter: ollama
161
+ usageBaseURL: https://ollama.example.com
162
+ credentialRef: OLLAMA_API_KEY # 非已配置 provider 时必填
163
+ ```
164
+
146
165
  Z.ai 全球区使用 `api.z.ai`,中国区使用 `open.bigmodel.cn`。MiniMax 优先使用官方 `www.minimax.io` / `www.minimaxi.com` Token Plan 地址,并解析 5 小时与周窗口的剩余比例和重置时间。
147
166
 
148
167
  ### New API、Sub2API 与自定义 monitor
149
168
 
150
- 在现有 `name: dsh-usage-stats` Cordis entry 下合并 `config`,不要追加第二个插件 entry。monitor 键必须是 Harness 中真实存在的 provider id;未知 provider、adapter 或非法映射会在路由和 timer 注册前阻止插件启动。例外:monitor 同时显式提供 `usageBaseURL` 与 `credentialRef` 时视为自包含,会在 provider 注册可见前临时物化为 provider(适用于 Harness 设置页里后加载的 provider),此时不要求该 provider 已出现在注册表中。
169
+ 在现有 `name: "@ychris12138/dsh-usage-stats"` Cordis entry 下合并 `config`,不要追加第二个插件 entry。monitor 键必须是 Harness 中真实存在的 provider id;未知 provider、adapter 或非法映射会在路由和 timer 注册前阻止插件启动。例外:monitor 同时显式提供 `usageBaseURL` 与 `credentialRef` 时视为自包含,会在 provider 注册可见前临时物化为 provider(适用于 Harness 设置页里后加载的 provider),此时不要求该 provider 已出现在注册表中。
151
170
 
152
171
  <details>
153
172
  <summary><strong>展开 monitor 配置示例</strong></summary>
@@ -158,7 +177,7 @@ New API 默认用 provider 推理 Token 查询 `/api/usage/token/`,并从 `/ap
158
177
  # ~/.dsh/profiles/web/cordis.patch.yml
159
178
  - insert:
160
179
  - id: usage-stats
161
- name: dsh-usage-stats
180
+ name: "@ychris12138/dsh-usage-stats"
162
181
  config:
163
182
  monitors:
164
183
  relay-a: # Harness provider id
@@ -264,7 +283,7 @@ Constraints:
264
283
 
265
284
  Procedure:
266
285
  1. Confirm node, npx, and dsh are available.
267
- 2. Prefer `dsh plugin --profile web update dsh-usage-stats` when already installed; otherwise use `dsh plugin --profile web add "github:Ychris12138/dsh-usage-stats"`.
286
+ 2. Prefer `dsh plugin --profile web update "@ychris12138/dsh-usage-stats"` when already installed; otherwise use `dsh plugin --profile web add "github:Ychris12138/dsh-usage-stats"`.
268
287
  3. If unavailable, use: npx --yes github:Ychris12138/dsh-usage-stats
269
288
  4. Do not combine bundle installation with an existing manual dsh-usage-stats Cordis entry.
270
289
  5. For npx, require a verified package and exactly one Cordis entry, then run again with --check.
package/lib/accounts.js CHANGED
@@ -52,6 +52,7 @@ const ADAPTERS = new Set([
52
52
  "zai-token-plan",
53
53
  "kimi-token-plan",
54
54
  "minimax-token-plan",
55
+ "ollama",
55
56
  "declarative"
56
57
  ]);
57
58
  const SENSITIVE_HEADERS = new Set([
@@ -189,6 +190,15 @@ function defaultAdapter(provider) {
189
190
  try {
190
191
  const hostname = new URL(provider.baseURL).hostname.toLowerCase();
191
192
  if (hostname === "passionapi.com" || hostname.endsWith(".passionapi.com")) return "sub2api";
193
+ // Ollama Cloud is detected by its canonical host so a user-configured
194
+ // provider with a custom id still gets the quota adapter; local Ollama
195
+ // (localhost:11434) never matches and stays without an account adapter.
196
+ // The id check is gated on the hostname so a local install that happens
197
+ // to use the canonical "ollama" id is not misread as a cloud account.
198
+ // Subdomains (e.g. api.ollama.com) are recognized for identification
199
+ // only: the usage query always targets the canonical ollama.com host,
200
+ // which is where the /api/usage endpoint is served.
201
+ if ((providerId === "ollama" || hostname === "ollama.com" || hostname.endsWith(".ollama.com")) && !privateHostname(hostname)) return "ollama";
192
202
  } catch {
193
203
  // A malformed provider URL is handled by the adapter when it is queried.
194
204
  }
@@ -198,7 +208,7 @@ function defaultAdapter(provider) {
198
208
 
199
209
  function adapterMode(adapter, monitor) {
200
210
  if (adapter === "declarative") return monitor.mode;
201
- if (["opencode-go", "zai-token-plan", "kimi-token-plan", "minimax-token-plan"].includes(adapter)) return "subscription";
211
+ if (["opencode-go", "zai-token-plan", "kimi-token-plan", "minimax-token-plan", "ollama"].includes(adapter)) return "subscription";
202
212
  return "balance";
203
213
  }
204
214
 
@@ -1118,7 +1128,8 @@ export async function queryAccount(spec, credentials, deps = {}) {
1118
1128
  const subscriptionId = spec.adapter === "zai-token-plan" ? "zai"
1119
1129
  : spec.adapter === "kimi-token-plan" ? "kimi"
1120
1130
  : spec.adapter === "minimax-token-plan" ? "minimax"
1121
- : "opencode-go";
1131
+ : spec.adapter === "ollama" ? "ollama"
1132
+ : "opencode-go";
1122
1133
  const provider = await collectSubscription(subscriptionId, credentials, {
1123
1134
  apiKeyRef: spec.apiKeyRef,
1124
1135
  region: spec.monitor.region
package/lib/client.js CHANGED
@@ -62,6 +62,7 @@ window.__ModuleLoader__.load({
62
62
  ".usg_accountCard{--usg-providerAccent:#1f6feb;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);background:linear-gradient(135deg,color-mix(in srgb,var(--usg-providerAccent) 8%,transparent),transparent 42%);border-radius:12px;padding:10px 11px;display:flex;flex-direction:column;gap:9px}",
63
63
  ".usg_accountCard[data-provider=deepseek],.usg_accountCard[data-provider=deepseek-official]{--usg-providerAccent:#1f6feb}",
64
64
  ".usg_accountCard[data-provider=opencode-go]{--usg-providerAccent:#00a67d}",
65
+ ".usg_accountCard[data-provider=ollama],.usg_accountCard[data-adapter=ollama]{--usg-providerAccent:#d97706}",
65
66
  ".usg_accountCard[data-provider=zai],.usg_accountCard[data-provider=zai-coding-cn]{--usg-providerAccent:#7656e8}",
66
67
  ".usg_accountCard[data-provider=openrouter]{--usg-providerAccent:#6366f1}",
67
68
  ".usg_accountCard[data-provider=moonshotai],.usg_accountCard[data-provider=moonshotai-cn],.usg_accountCard[data-provider=kimi],.usg_accountCard[data-provider=kimi-coding]{--usg-providerAccent:#e07a1f}",
@@ -139,7 +140,7 @@ window.__ModuleLoader__.load({
139
140
  const tagId = "dsh-usage-stats/UsageStats.module.css";
140
141
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
141
142
  const tag = document.createElement("style");
142
- tag.dataset.plugin = "dsh-usage-stats";
143
+ tag.dataset.plugin = "@ychris12138/dsh-usage-stats";
143
144
  tag.dataset.pluginCss = tagId;
144
145
  tag.textContent = css;
145
146
  document.head.appendChild(tag);
@@ -960,6 +961,7 @@ window.__ModuleLoader__.load({
960
961
  "deepseek-official": "DS",
961
962
  deepseek: "DS",
962
963
  "opencode-go": "GO",
964
+ ollama: "OL",
963
965
  openrouter: "OR",
964
966
  moonshotai: "K",
965
967
  "moonshotai-cn": "K",
@@ -968,7 +970,7 @@ window.__ModuleLoader__.load({
968
970
  zai: "Z",
969
971
  "zai-coding-cn": "Z"
970
972
  };
971
- return known[provider.id] ?? String(provider.displayName ?? provider.id).replace(/[^A-Za-z0-9]/g, "").slice(0, 2).toUpperCase();
973
+ return known[provider.id] ?? known[provider.adapter] ?? String(provider.displayName ?? provider.id).replace(/[^A-Za-z0-9]/g, "").slice(0, 2).toUpperCase();
972
974
  }
973
975
 
974
976
  /** Balance-mode body rendered inside the shared provider account frame. */
@@ -1129,6 +1131,7 @@ window.__ModuleLoader__.load({
1129
1131
  return react_jsx_runtime.jsxs("article", {
1130
1132
  className: S.accountCard,
1131
1133
  "data-provider": provider.id,
1134
+ "data-adapter": provider.adapter ?? provider.accountMode ?? "",
1132
1135
  "data-account-mode": mode,
1133
1136
  children: [
1134
1137
  react_jsx_runtime.jsxs("div", {
@@ -38,6 +38,7 @@ const MINIMAX_LEGACY_HOSTS = {
38
38
  };
39
39
  const MINIMAX_USAGE_PATH = "/v1/api/openplatform/coding_plan/remains";
40
40
  const MINIMAX_TOKEN_PLAN_PATH = "/v1/token_plan/remains";
41
+ const OLLAMA_USAGE_URL = "https://ollama.com/api/usage";
41
42
  const DEFAULT_TIMEOUT_MS = 15000;
42
43
 
43
44
  const REFS = {
@@ -48,7 +49,8 @@ const REFS = {
48
49
  zaiRegion: "ZAI_API_REGION",
49
50
  kimiApiKey: "KIMI_API_KEY",
50
51
  minimaxApiKey: "MINIMAX_API_KEY",
51
- minimaxRegion: "MINIMAX_API_REGION"
52
+ minimaxRegion: "MINIMAX_API_REGION",
53
+ ollamaApiKey: "OLLAMA_API_KEY"
52
54
  };
53
55
 
54
56
  function numberOrNull(value) {
@@ -576,6 +578,61 @@ async function collectMiniMax(credentials, deps) {
576
578
  }
577
579
  }
578
580
 
581
+ /**
582
+ * Ollama Cloud usage monitor.
583
+ *
584
+ * Ollama's cloud /api/usage endpoint reports consumed usage ratios for two
585
+ * limit windows (a 5-hour session window and a weekly window), plus an
586
+ * activity cost. There is no monetary balance, so this adapter presents the
587
+ * two windows as subscription-style progress bars, mirroring OpenCode Go.
588
+ */
589
+ function ollamaWindowFromObject(limit, kind) {
590
+ if (limit === null || typeof limit !== "object") return null;
591
+ // limits.session.usage / limits.weekly.usage are 0..1 consumed ratios
592
+ // (observed 0.0x..0.3x on live data). clampPercent maps any numeric ratio
593
+ // onto 0..100, so an over-quota window renders as a fully-used bar rather
594
+ // than silently disappearing, mirroring the OpenCode Go adapter.
595
+ const ratio = numberOrNull(limit.usage);
596
+ if (ratio === null) return null;
597
+ const usedPercent = round1(clampPercent(ratio * 100));
598
+ return {
599
+ kind,
600
+ usedPercent,
601
+ remainingPercent: round1(100 - usedPercent)
602
+ };
603
+ }
604
+
605
+ function parseOllama(body) {
606
+ const limits = body?.limits;
607
+ if (limits === null || typeof limits !== "object") return [];
608
+ return [
609
+ ollamaWindowFromObject(limits.session, "session"),
610
+ ollamaWindowFromObject(limits.weekly, "weekly")
611
+ ].filter(Boolean);
612
+ }
613
+
614
+ async function collectOllama(credentials, deps) {
615
+ const apiKeyRef = deps.apiKeyRef ?? REFS.ollamaApiKey;
616
+ const apiKey = await resolveCredential(credentials, apiKeyRef);
617
+ if (apiKey === "") return { id: "ollama", displayName: "Ollama", mode: "subscription", status: "not-configured", plan: "Ollama", missingCredentials: [apiKeyRef], windows: [] };
618
+ try {
619
+ const body = await request(nonEmptyUrl(deps.baseURL, "/api/usage") ?? OLLAMA_USAGE_URL, {
620
+ headers: { authorization: `Bearer ${apiKey}`, accept: "application/json" }
621
+ }, deps, "json");
622
+ const windows = parseOllama(body);
623
+ return {
624
+ id: "ollama",
625
+ displayName: "Ollama",
626
+ mode: "subscription",
627
+ status: windows.length > 0 ? "ok" : "invalid-response",
628
+ plan: "Ollama",
629
+ windows
630
+ };
631
+ } catch (error) {
632
+ return { id: "ollama", displayName: "Ollama", mode: "subscription", status: normalizedStatus(error), plan: "Ollama", windows: [] };
633
+ }
634
+ }
635
+
579
636
  /** Query one subscription/token-plan adapter. */
580
637
  export async function collectSubscription(providerId, credentials, options = {}, deps = {}) {
581
638
  const shared = {
@@ -596,6 +653,7 @@ export async function collectSubscription(providerId, credentials, options = {},
596
653
  });
597
654
  if (providerId === "kimi") return collectKimi(credentials, shared);
598
655
  if (providerId === "minimax") return collectMiniMax(credentials, shared);
656
+ if (providerId === "ollama") return collectOllama(credentials, shared);
599
657
  return { id: providerId, displayName: providerId, mode: "subscription", status: "unavailable", windows: [] };
600
658
  }
601
659
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ychris12138/dsh-usage-stats",
3
3
  "description": "Token usage heatmap, provider balances, and subscription quotas for the dsh web GUI",
4
- "version": "0.2.8",
4
+ "version": "0.2.10",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/Ychris12138/dsh-usage-stats.git"
@@ -33,13 +33,24 @@ Set DSH_HOME to override the default ~/.dsh location.`);
33
33
  const sourceRoot = dirname(dirname(fileURLToPath(import.meta.url)));
34
34
  const sourcePackage = JSON.parse(await readFile(join(sourceRoot, "package.json"), "utf8"));
35
35
  const dshHome = process.env.DSH_HOME ?? join(homedir(), ".dsh");
36
- const target = join(dshHome, "profiles", "node_modules", "dsh-usage-stats");
36
+ const packagePath = sourcePackage.name.split("/");
37
+ if (packagePath.some((part) => part === "" || part === "." || part === "..")) {
38
+ throw new Error(`invalid package name: ${sourcePackage.name}`);
39
+ }
40
+ const target = join(dshHome, "profiles", "node_modules", ...packagePath);
37
41
  const patchPath = join(dshHome, "profiles", "web", "cordis.patch.yml");
38
- const pluginLine = /^\s+name:\s*dsh-usage-stats\s*$/gm;
42
+ const legacyPackageName = sourcePackage.name.split("/").at(-1);
43
+ const quotedPackageName = JSON.stringify(sourcePackage.name);
44
+ const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
45
+ const scopedNamePattern = escapeRegExp(sourcePackage.name);
46
+ const legacyNamePattern = escapeRegExp(legacyPackageName);
47
+ const pluginLine = new RegExp(`^\\s+name:\\s*(?:"${scopedNamePattern}"|'${scopedNamePattern}')\\s*$`, "gm");
48
+ const unquotedScopedPluginLine = new RegExp(`^(\\s+name:\\s*)${scopedNamePattern}\\s*$`, "gm");
49
+ const legacyPluginLine = new RegExp(`^(\\s+name:\\s*)(?:"${legacyNamePattern}"|'${legacyNamePattern}'|${legacyNamePattern})\\s*$`, "gm");
39
50
  const patchBlock = `# dsh-usage-stats: token usage heatmap + DeepSeek balance
40
51
  - insert:
41
52
  - id: usage-stats
42
- name: dsh-usage-stats
53
+ name: ${quotedPackageName}
43
54
  `;
44
55
  const emptySequenceRoot = /^\[\](?:[ \t]+#.*)?$/;
45
56
 
@@ -76,10 +87,26 @@ function assertNoEmptyRootConflict(text) {
76
87
  }
77
88
  }
78
89
 
90
+ function countMatches(text, pattern) {
91
+ return [...String(text).matchAll(pattern)].length;
92
+ }
93
+
94
+ /** Migrate the pre-scope entry and repair an invalid unquoted scoped YAML value. */
95
+ function normalizePluginIdentity(text) {
96
+ const scopedCount = countMatches(text, pluginLine);
97
+ const legacyCount = countMatches(text, legacyPluginLine) + countMatches(text, unquotedScopedPluginLine);
98
+ if (scopedCount + legacyCount > 1) {
99
+ throw new Error(`multiple dsh-usage-stats entries in ${patchPath}; keep one ${quotedPackageName} entry`);
100
+ }
101
+ return String(text)
102
+ .replace(legacyPluginLine, (_, prefix) => `${prefix}${quotedPackageName}`)
103
+ .replace(unquotedScopedPluginLine, (_, prefix) => `${prefix}${quotedPackageName}`);
104
+ }
105
+
79
106
  /** Preserve existing YAML/comments while adding exactly one plugin patch entry. */
80
107
  function enablePluginInPatch(text) {
81
- const base = withoutEmptySequenceRoot(text);
82
- if ([...base.matchAll(pluginLine)].length > 0) return base;
108
+ const base = normalizePluginIdentity(withoutEmptySequenceRoot(text));
109
+ if (countMatches(base, pluginLine) > 0) return base;
83
110
  return base.trim() === "" ? patchBlock : `${base.trimEnd()}\n\n${patchBlock}`;
84
111
  }
85
112
 
@@ -100,10 +127,12 @@ async function verify(expectEnabled) {
100
127
  throw new Error(`installed package is ${installed.name ?? "unknown"}@${installed.version ?? "unknown"}; expected ${sourcePackage.name}@${sourcePackage.version}`);
101
128
  }
102
129
  if (expectEnabled) {
103
- const patch = await readOptional(patchPath);
104
- const count = patch === null ? 0 : [...patch.matchAll(pluginLine)].length;
105
- if (count !== 1) throw new Error(`expected exactly one dsh-usage-stats entry in ${patchPath}; found ${count}`);
130
+ const patch = await readOptional(patchPath) ?? "";
106
131
  assertNoEmptyRootConflict(patch);
132
+ const count = countMatches(patch, pluginLine);
133
+ if (count !== 1) throw new Error(`expected exactly one dsh-usage-stats entry in ${patchPath}; found ${count}`);
134
+ if (countMatches(patch, legacyPluginLine) > 0) throw new Error(`legacy package name ${legacyPackageName} remains in ${patchPath}`);
135
+ if (countMatches(patch, unquotedScopedPluginLine) > 0) throw new Error(`scoped package name must be quoted in ${patchPath}`);
107
136
  }
108
137
  console.log(`Verified ${sourcePackage.name}@${sourcePackage.version}`);
109
138
  console.log(` package: ${target}`);
@@ -139,4 +168,4 @@ if (enable) {
139
168
 
140
169
  await verify(enable);
141
170
  console.log("Installation complete. Restart dsh web, then hard-refresh the browser.");
142
- console.log("Balance is optional: configure DEEPSEEK_API_KEY in <DSH_HOME>/.credentials.yaml.");
171
+ console.log("Balance is optional: configure DEEPSEEK_API_KEY in <DSH_HOME>/.credentials.yaml.");