dsh-subusage 0.10.5 → 0.10.6
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 +5 -4
- package/lib/client.js +8 -3
- package/lib/index.js +42 -6
- package/lib/volcengine.js +4 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
> **English.** dsh-subusage shows subscription quota and balance for the AI providers you already use, as a pill next to the model selector in DeepSeek Harness. Provider switches and credentials live in one settings page, and other plugins or agents can read the same snapshot through a read-only quota API.
|
|
8
8
|
|
|
9
|
-
[](https://www.npmjs.com/package/dsh-subusage)
|
|
10
10
|
[](#license--security--许可与安全)
|
|
11
11
|
[](#compatibility--兼容性)
|
|
12
12
|
|
|
@@ -142,7 +142,8 @@ $env:DEEPSEEK_API_KEY = "<你的 DeepSeek API Key>" # 启动环境;改完需
|
|
|
142
142
|
- **设置页路径**:设置 → **订阅用量** → 提供商管理。「没有检测到API的默认隐藏」默认开启;首次没有可见条目时管理区自动展开。关闭的提供商仍可配置凭据,只是不会发起用量请求。
|
|
143
143
|
- **凭据从哪来**:每一类凭据的**官网入口、环境变量名与界面填入位置**都写在[凭据获取指引](docs/credentials.md)——包括火山的 IAM AK/SK(子用户还要挂 `ArkReadOnlyAccess` 且不限制到项目)、MiMo 的平台会话 Cookie、以及各家 API Key 的对照表。**注意推理 Key 与额度凭据不通用。**
|
|
144
144
|
- **默认开关策略**:新安装默认开启 10 条、默认关闭 20 条(**Codex 默认关闭**——它的提供方插件自带用量药丸);开关即时保存,升级保留已保存的开关,不重算默认值。默认隐藏只影响显示,不会自动开启被关闭的提供商。
|
|
145
|
-
- **火山方舟是一组**:7 条路由共用同一组 IAM AK/SK,所以设置页只呈现**一张卡片、一个开关**(组内最后选的那条作为代表),卡片里写明本机实际装了哪几条。这些 provider id 并没有合并——药丸仍按 id
|
|
145
|
+
- **火山方舟是一组**:7 条路由共用同一组 IAM AK/SK,所以设置页只呈现**一张卡片、一个开关**(组内最后选的那条作为代表),卡片里写明本机实际装了哪几条。这些 provider id 并没有合并——药丸仍按 id 匹配路由。
|
|
146
|
+
- **火山方舟不按路由收起**:它的额度走**账号级管控面**,一组 AK/SK 就能查到名下所有套餐(`GetCodingPlanUsage` 与 `GetAFPUsage` 是同一套协议、同一组凭据)。推理路由装没装只影响模型能不能用,**不影响额度能不能查**——所以只持有 Coding Plan、却没在 DSH 里配 coding-plan 路由时,那条额度照样会显示(前提是配了 AK/SK)。
|
|
146
147
|
- **凭据来源优先级**(`inherit` 模式):**凭据服务 → 启动环境 → 旧手动配置兜底**;切到自定义(`manual`)模式时只使用保存的手动 Key。界面会区分这三种来源。
|
|
147
148
|
- **不接受本地 Key 的提供商**:`commandcode`(凭据链属提供方插件)、`xai-oauth`、`openai-codex`(只读各自 CLI 的登录文件),以及 MiMo(Cookie 会话)。
|
|
148
149
|
- **保存与验证分离**:凭据先确认持久化,再独立做在线验证;检测关闭的提供商只保存凭据、不发起验证,验证失败不代表保存失败。
|
|
@@ -164,9 +165,9 @@ $env:DEEPSEEK_API_KEY = "<你的 DeepSeek API Key>" # 启动环境;改完需
|
|
|
164
165
|
| MiniMax(国际) | `MINIMAX_API_KEY` | `minimax` 路由;国际站订阅 Key |
|
|
165
166
|
| MiniMax(中国) | `MINIMAX_CN_API_KEY` | `minimax-cn` 路由;中国站订阅 Key |
|
|
166
167
|
| Command Code | `COMMANDCODE_API_KEY` | 凭据由提供方插件管理,本页只读继承;兜底读取 `~/.commandcode/auth.json`(`cmd login`) |
|
|
167
|
-
| SuperGrok | 无 | `xai-oauth` 路由;只读 dsh-grok-kit / Grok CLI 共享的 OAuth 登录文件 `~/.grok/auth.json`(旧版 `~/.dsh/.xai-oauth-auth.json
|
|
168
|
+
| SuperGrok | 无 | `xai-oauth` 路由;只读 dsh-grok-kit / Grok CLI 共享的 OAuth 登录文件 `~/.grok/auth.json`(旧版 `~/.dsh/.xai-oauth-auth.json`),不保存、**不刷新 token**;access token 过期时直接说明过期时间并提示去刷(不代刷——refresh-token 轮换由 Grok CLI / grok-kit 各自的锁协议管理,第三端刷新会把它们的轮换顶掉)。API Key(`XAI_API_KEY`)取不到订阅周池 |
|
|
168
169
|
| Codex | 无 | `openai-codex` 路由;只读 Codex CLI 的 ChatGPT 订阅登录文件(`$CODEX_HOME/auth.json` 或 `~/.codex/auth.json`,要求 `auth_mode` 为 `chatgpt`),不保存、不刷新 token |
|
|
169
|
-
| 火山方舟 Ark(7 条路由) | `VOLC_ACCESSKEY` + `VOLC_SECRETKEY` | 额度查询要 **IAM Access Key 的 AK/SK 配对**,与推理 API Key(`ARKCLI_*_API_KEY` / `ARK_*_API_KEY`)**不是同一套**;7 条路由共用一组 AK/SK |
|
|
170
|
+
| 火山方舟 Ark(7 条路由) | `VOLC_ACCESSKEY` + `VOLC_SECRETKEY` | 额度查询要 **IAM Access Key 的 AK/SK 配对**,与推理 API Key(`ARKCLI_*_API_KEY` / `ARK_*_API_KEY`)**不是同一套**;7 条路由共用一组 AK/SK。这一组 AK/SK 能**同时**查到 Agent Plan 与 Coding Plan(含团队版席位)——不存在「Coding Plan 专用查询 key」 |
|
|
170
171
|
| SiliconFlow | `SILICONFLOW_API_KEY` | 余额型:`GET /v1/user/info` 返回账户余额,**与推理是同一把 Key** |
|
|
171
172
|
| OpenRouter | `OPENROUTER_API_KEY` | key 限额与用量用推理 key 即可;**账户余额需要 management / provisioning key**,普通 key 会被 403 拒——此时静默降级为只显示限额,不判失败 |
|
|
172
173
|
| Synthetic | `SYNTHETIC_API_KEY` | 模型订阅请求额度 |
|
package/lib/client.js
CHANGED
|
@@ -313,14 +313,19 @@ window.__ModuleLoader__.load({
|
|
|
313
313
|
return settings?.visibility?.providers?.[id] ?? (PROVIDER_META[id]?.defaultEnabled !== false);
|
|
314
314
|
}
|
|
315
315
|
// provider id 就是路由 id,而路由由提供方插件/CLI 按「你实际订阅了什么」写入:例如 arkcli helper
|
|
316
|
-
//
|
|
317
|
-
//
|
|
316
|
+
// 只会为真正持有的套餐写路由。对**按路由取数的**提供商来说,「本机没有这条路由」就等于「没订阅」,
|
|
317
|
+
// 默认收起比「常量里默认开启」更贴近事实。undefined = 拿不到路由表,按「未知」放行,不谎报未安装。
|
|
318
|
+
// 例外是火山方舟:它的额度走账号级管控面(一组 AK/SK 覆盖名下所有套餐),路由只是推理入口 —— 见 providerVisible。
|
|
318
319
|
function providerRoutable(result, id) { return result?.configured?.[id] !== false; }
|
|
319
320
|
function providerVisible(settings, id, entry, result) {
|
|
320
321
|
if (!providerEnabled(settings, id)) return false;
|
|
321
322
|
// 关掉「没有检测到 API 的默认隐藏」就是要看全部,不再按路由或凭据收起。
|
|
322
323
|
if (settings?.visibility?.hideWithoutApi === false) return true;
|
|
323
|
-
|
|
324
|
+
// 火山方舟**不按路由收起**:额度是账号级订阅,与「本机装没装这条推理路由」无关——一组 IAM AK/SK
|
|
325
|
+
// 就能查到名下所有套餐(GetCodingPlanUsage 与 GetAFPUsage 同协议同凭据,2026-10-10 实测通过)。
|
|
326
|
+
// 路由只是模型推理入口,「订了 Coding Plan、却没在 DSH 里配 coding-plan 路由」完全正常;
|
|
327
|
+
// 按路由收起会让这类用户凭空少一条真正有数据的额度,看到的是「本机什么都没有」。
|
|
328
|
+
if (!PROVIDER_META[id]?.volc && !providerRoutable(result, id)) return false;
|
|
324
329
|
return entry?.apiDetected !== false && !["no-key", "no-cookie"].includes(entry?.state);
|
|
325
330
|
}
|
|
326
331
|
/** token 数紧凑格式:1,234,567,890 → 1.23B */
|
package/lib/index.js
CHANGED
|
@@ -85,6 +85,16 @@ const IDS = Object.keys(PROVIDERS);
|
|
|
85
85
|
// 用一个组级开关控制整组。这些 id 仍各自存在——药丸要靠 provider id 匹配路由,不能合并。
|
|
86
86
|
const ARK_IDS = IDS.filter(id => PROVIDERS[id].credentialKind === "volc");
|
|
87
87
|
const ARK_DEFAULT_ON = ARK_IDS.some(id => PROVIDERS[id].defaultEnabled !== false);
|
|
88
|
+
/**
|
|
89
|
+
* 某条路由当前是否启用。Ark 以**组开关为唯一真源**——7 条路由共用一组 IAM AK/SK、一般用户只
|
|
90
|
+
* 持有一个套餐,所以设置页只给一个开关。这里必须与 Client 的 `providerEnabled` 同语义:
|
|
91
|
+
* 两端口径一旦分叉,界面按组开关显示这条路由,Host 却按逐条值回 `disabled`,
|
|
92
|
+
* 用户拿到的就是一张写着「检测已关闭」的空卡片(这正是 Coding Plan 查不到用量的原因)。
|
|
93
|
+
*/
|
|
94
|
+
function providerEnabled(settings, id) {
|
|
95
|
+
if (PROVIDERS[id]?.credentialKind === "volc" && typeof settings?.visibility?.ark === "boolean") return settings.visibility.ark;
|
|
96
|
+
return !!settings?.visibility?.providers?.[id];
|
|
97
|
+
}
|
|
88
98
|
/**
|
|
89
99
|
* 调用方(尤其是模型)手里的「提供商名」通常**不是**本插件的 provider id。模型能看到的往往是
|
|
90
100
|
* DSH 的 llm 路由名或厂商品牌名(`zai`、`grok`、`codex`、`ark`),而这里的 id 是路由 id
|
|
@@ -199,6 +209,16 @@ function bearerSubject(key) {
|
|
|
199
209
|
// ({ version, credential: { access, accountId } })。槽位优先 auth.x.ai,其次旧签发方。
|
|
200
210
|
function grokAuthFileCredentials(io) {
|
|
201
211
|
const str = v => typeof v === "string" && v.trim() ? v.trim() : undefined;
|
|
212
|
+
// access token 短命(实测 ~24 小时量级)。登录文件里带着 `expires_at`,必须把它读出来:
|
|
213
|
+
// 不读就只能等上游回 401,界面显示一句「登录或凭据已失效」,而用户那边的 Grok Kit 明明写着「已登录」。
|
|
214
|
+
const expiry = value => {
|
|
215
|
+
if (typeof value === "string" && Number.isFinite(Date.parse(value))) return new Date(value).toISOString();
|
|
216
|
+
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
|
217
|
+
const ms = value > 1e12 ? value : value * 1000;
|
|
218
|
+
return Number.isFinite(new Date(ms).getTime()) ? new Date(ms).toISOString() : undefined;
|
|
219
|
+
}
|
|
220
|
+
return undefined;
|
|
221
|
+
};
|
|
202
222
|
const read = path => {
|
|
203
223
|
try {
|
|
204
224
|
const parsed = JSON.parse(io.readFileSync(path, "utf8"));
|
|
@@ -206,7 +226,9 @@ function grokAuthFileCredentials(io) {
|
|
|
206
226
|
const envelope = parsed.credential && typeof parsed.credential === "object" && !Array.isArray(parsed.credential) ? parsed.credential : undefined;
|
|
207
227
|
const pick = (record, userId) => {
|
|
208
228
|
const key = str(record.key) ?? str(record.access) ?? str(record.access_token);
|
|
209
|
-
|
|
229
|
+
if (!key) return undefined;
|
|
230
|
+
const expiresAt = expiry(record.expires_at ?? record.expiresAt);
|
|
231
|
+
return { key, userId: str(record.user_id) ?? str(record.principal_id) ?? userId ?? bearerSubject(key), ...(expiresAt ? { expiresAt } : {}) };
|
|
210
232
|
};
|
|
211
233
|
if (envelope) return pick(envelope, str(envelope.accountId));
|
|
212
234
|
const slots = Object.entries(parsed).filter(([, v]) => v && typeof v === "object" && !Array.isArray(v) && (str(v.key) ?? str(v.access) ?? str(v.access_token)));
|
|
@@ -251,7 +273,9 @@ function redactMimo(value, cookie) {
|
|
|
251
273
|
return { ...value, ...(Array.isArray(value.extras) ? { extras: value.extras.map(extra => extra.kind === "plan" ? { ...extra, value: sanitize(extra.value) } : extra) } : {}) };
|
|
252
274
|
}
|
|
253
275
|
function defaultSettings() {
|
|
254
|
-
|
|
276
|
+
// Ark 的逐条默认值必须直接跟随组开关:`load()` 在配置文件还不存在时走的就是这里,若默认值各行其是,
|
|
277
|
+
// 「首次读到 → 保存 → 再读回」会因为归一化把成员值改成组开关的值而算出不同的 revision。
|
|
278
|
+
return { zai: { type: 1, organization: "", project: "" }, volc: { accessKeyId: "", secretAccessKey: "" }, litellm: { baseUrl: "" }, xiaomi: { cookie: "", loginAt: 0, expiresAt: 0 }, keys: {}, keyModes: Object.fromEntries(IDS.map(id => [id, "inherit"])), visibility: { hideWithoutApi: true, ark: ARK_DEFAULT_ON, providers: Object.fromEntries(IDS.map(id => [id, PROVIDERS[id].credentialKind === "volc" ? ARK_DEFAULT_ON : PROVIDERS[id].defaultEnabled !== false])) } };
|
|
255
279
|
}
|
|
256
280
|
function parseStored(value) {
|
|
257
281
|
const s = defaultSettings();
|
|
@@ -270,6 +294,10 @@ function parseStored(value) {
|
|
|
270
294
|
if (typeof value.keys?.[id] === "string") s.keys[id] = value.keys[id];
|
|
271
295
|
if (value.keyModes?.[id] === "manual") s.keyModes[id] = "manual";
|
|
272
296
|
}
|
|
297
|
+
// 组开关是 Ark 的**唯一真源**:逐条值只当作迁移输入,读回后一律跟随组开关。
|
|
298
|
+
// 不归一化就会分叉——「迁移时把任一条开着合并成组开关 true、成员值却保留原样」之后,
|
|
299
|
+
// 界面按组开关显示这条路由,Host 却按成员值判定,用户拿到的是一张写着「检测已关闭」的空卡片。
|
|
300
|
+
for (const id of ARK_IDS) s.visibility.providers[id] = s.visibility.ark;
|
|
273
301
|
return s;
|
|
274
302
|
}
|
|
275
303
|
function publicSettings(s) {
|
|
@@ -371,7 +399,7 @@ function volcFailure(action, error) {
|
|
|
371
399
|
}
|
|
372
400
|
async function fetchJson(fetcher, url, headers, lifetimeSignal, init = {}) {
|
|
373
401
|
let response;
|
|
374
|
-
try { response = await fetcher(url, { method: init.method ?? "GET", ...(init.body !== undefined ? { body: init.body } : {}), headers: { accept: "application/json", "user-agent": "dsh-subusage/0.10.
|
|
402
|
+
try { response = await fetcher(url, { method: init.method ?? "GET", ...(init.body !== undefined ? { body: init.body } : {}), headers: { accept: "application/json", "user-agent": "dsh-subusage/0.10.6", ...headers }, signal: lifetimeSignal ? AbortSignal.any([AbortSignal.timeout(10000), lifetimeSignal]) : AbortSignal.timeout(10000), redirect: "manual" }); }
|
|
375
403
|
catch { throw failure("subusage/network", "网络请求失败或超时", true); }
|
|
376
404
|
if (response.status >= 300 && response.status < 400 || response.status === 401 || response.status === 403) throw failure("subusage/auth", "登录或凭据已失效");
|
|
377
405
|
if (!response.ok) {
|
|
@@ -887,9 +915,10 @@ export class SubUsageService extends TypertRemoteService {
|
|
|
887
915
|
if (PROVIDERS[id].credentialKind === "volc") return await this.volcIdentity(id, s);
|
|
888
916
|
if (id === "xai-oauth") {
|
|
889
917
|
// SuperGrok 只认共享登录文件里的 OAuth Bearer:token 短命轮换,凭据服务/环境变量副本
|
|
890
|
-
// 必然过期顶掉新 token,故不走通用 Key 链;user_id 随行给 x-userid
|
|
918
|
+
// 必然过期顶掉新 token,故不走通用 Key 链;user_id 随行给 x-userid 请求头,
|
|
919
|
+
// expiresAt 用来在过期时给出「去刷一次」而不是「凭据已失效」。
|
|
891
920
|
const cred = await this.grokAuth();
|
|
892
|
-
return cred?.key ? { key: cred.key, source: "auth-file", ...(cred.userId ? { userId: cred.userId } : {}) } : { key: undefined, source: "none" };
|
|
921
|
+
return cred?.key ? { key: cred.key, source: "auth-file", ...(cred.userId ? { userId: cred.userId } : {}), ...(cred.expiresAt ? { expiresAt: cred.expiresAt } : {}) } : { key: undefined, source: "none" };
|
|
893
922
|
}
|
|
894
923
|
if (id === "openai-codex") {
|
|
895
924
|
// 同上:只读 Codex CLI 的 OAuth 登录文件,token 由 Codex CLI 轮换,第三端刷新会互相顶掉。
|
|
@@ -1047,6 +1076,13 @@ export class SubUsageService extends TypertRemoteService {
|
|
|
1047
1076
|
return normalizeCommandCode(credits, subscription);
|
|
1048
1077
|
}
|
|
1049
1078
|
if (id === "xai-oauth") {
|
|
1079
|
+
// 登录文件里的 access token 短命(实测 ~24 小时)。本插件**刻意不代刷**——refresh-token 轮换由
|
|
1080
|
+
// Grok CLI / dsh-grok-kit 各自的锁协议管理,第三端刷新会把它们的轮换顶掉。但「过期」这件事必须
|
|
1081
|
+
// 说清楚:不发这一枪的话,用户看到的是上游 401 转成的「登录或凭据已失效」,而他那边的 Grok Kit
|
|
1082
|
+
// 明明写着「已登录」——两边对不上,只会让人以为插件坏了。
|
|
1083
|
+
if (identity.expiresAt && Date.parse(identity.expiresAt) <= this.now()) {
|
|
1084
|
+
throw failure("subusage/auth", `Grok 登录文件的 access token 已于 ${new Date(identity.expiresAt).toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" })} 过期。用一次 Grok CLI,或在 设置 → xAI Grok 里重新登录,都会刷新同一个文件(${GROK_AUTH_FILE});本插件不代刷,以免与它们的 refresh-token 轮换互相顶掉。`);
|
|
1085
|
+
}
|
|
1050
1086
|
// 直连官方 Grok CLI 计费代理;X-XAI-Token-Auth 的取值是 token_header 字符串(xai-grok-cli)
|
|
1051
1087
|
// 而非布尔,x-userid 取登录文件。套餐名走 /v1/settings(subscription_tier_display),
|
|
1052
1088
|
// 失败不阻塞用量(回退 billing 响应里的 subscription_tier)。
|
|
@@ -1084,7 +1120,7 @@ export class SubUsageService extends TypertRemoteService {
|
|
|
1084
1120
|
catch { this.cache.delete(id); return { ...this.base(id, "none", settings, account), state: "error", errorCode: "subusage/credentials", error: "无法解析凭据" }; }
|
|
1085
1121
|
if (this.disposed) throw failure("subusage/disposed", "服务已卸载");
|
|
1086
1122
|
if (startedGeneration !== (this.generations.get(id) ?? 0)) return this.provider(id, this.load(), force, account);
|
|
1087
|
-
if (!settings
|
|
1123
|
+
if (!providerEnabled(settings, id)) {
|
|
1088
1124
|
this.cache.delete(id); this.inflight.delete(id);
|
|
1089
1125
|
return { ...this.base(id, identity.source, settings, account), state: "disabled", apiDetected: !!identity.key, windows: [], extras: [] };
|
|
1090
1126
|
}
|
package/lib/volcengine.js
CHANGED
|
@@ -110,7 +110,10 @@ const CODING_LEVELS = {
|
|
|
110
110
|
/**
|
|
111
111
|
* 个人版 Coding Plan:Result.QuotaUsage[] 每项一个窗口。
|
|
112
112
|
* Percent 是**已用百分数**(0–100,实测常见 0.39 这类小值,不能按「≤1 即小数」放大)。
|
|
113
|
-
* 接口只给百分比,不返回绝对量,因此不产出 detail
|
|
113
|
+
* 接口只给百分比,不返回绝对量,因此不产出 detail。
|
|
114
|
+
* 真实响应里还带 `Cap` / `RewardTotalPercent` / `HasReward`(2026-10-10 用本机 AK/SK 实测:三个窗口
|
|
115
|
+
* 都是 `Cap: 100`、`RewardTotalPercent: 0`、`HasReward: false`)——**字段确实存在**,但没有任何官方
|
|
116
|
+
* 说明它们的语义(像百分比刻度,也像次数上限),所以不展示:不编造单位,也不把 100 读成"还有 100 次"。
|
|
114
117
|
*/
|
|
115
118
|
export function parseVolcCodingPlan(body) {
|
|
116
119
|
// 正常走 OpenAPI 的 ResponseMetadata + Result 信封;个别抓包形态把字段放在顶层,两者都接受。
|