dsh-subusage 0.10.11 → 0.11.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 +101 -51
- package/lib/client.js +263 -88
- package/lib/index.js +314 -55
- package/package.json +3 -3
package/lib/index.js
CHANGED
|
@@ -11,6 +11,7 @@ import { MimoLogin } from "./mimo-login.js";
|
|
|
11
11
|
import { parseVolcAgentPlan, parseVolcCodingPlan, parseVolcSeatAfp, parseVolcSeatCoding, parseVolcSeatIds, volcError, volcSignature } from "./volcengine.js";
|
|
12
12
|
|
|
13
13
|
const name = "dsh-subusage";
|
|
14
|
+
const VERSION = "0.11.1";
|
|
14
15
|
const inject = ["llm"];
|
|
15
16
|
const PROVIDERS = {
|
|
16
17
|
"zai-coding-cn": { label: "Z.ai", envName: "ZAI_CODING_CN_API_KEY" },
|
|
@@ -41,7 +42,7 @@ const PROVIDERS = {
|
|
|
41
42
|
// 否则选中方舟模型时药丸不会出现。额度读取走管控面 OpenAPI,凭据是 IAM 的 AK/SK 配对
|
|
42
43
|
// (HMAC-SHA256 签名),与这些路由的推理 API Key 是两套:拿推理 Key 去查额度会被上游拒绝
|
|
43
44
|
// ("GetAFPUsage requires Volcengine Ark SSO STS")。
|
|
44
|
-
//
|
|
45
|
+
// 4 条 volc 路由共用一组 AK/SK(settings.volc):个人版各自固定一个 Action,
|
|
45
46
|
// 企业版/团队版走 ListSeatInfos → GetSeatAFPUsage / GetSeatInfoUsage 两步。
|
|
46
47
|
// ── arkcli(官方 CLI)安装的路由 ──────────────────────────────────────────
|
|
47
48
|
// 官方 CLI 的 `arkcli helper configure deepseek-harness` 把 provider 写成
|
|
@@ -87,7 +88,7 @@ const IDS = Object.keys(PROVIDERS);
|
|
|
87
88
|
const ARK_IDS = IDS.filter(id => PROVIDERS[id].credentialKind === "volc");
|
|
88
89
|
const ARK_DEFAULT_ON = ARK_IDS.some(id => PROVIDERS[id].defaultEnabled !== false);
|
|
89
90
|
/**
|
|
90
|
-
* 某条路由当前是否启用。Ark 以**组开关为唯一真源**——
|
|
91
|
+
* 某条路由当前是否启用。Ark 以**组开关为唯一真源**——4 条路由共用一组 IAM AK/SK、一般用户只
|
|
91
92
|
* 持有一个套餐,所以设置页只给一个开关。这里必须与 Client 的 `providerEnabled` 同语义:
|
|
92
93
|
* 两端口径一旦分叉,界面按组开关显示这条路由,Host 却按逐条值回 `disabled`,
|
|
93
94
|
* 用户拿到的就是一张写着「检测已关闭」的空卡片(这正是 Coding Plan 查不到用量的原因)。
|
|
@@ -118,7 +119,7 @@ const PROVIDER_ALIASES = {
|
|
|
118
119
|
function normalizeProviderKey(value) { return String(value ?? "").trim().toLowerCase().replace(/[\s._-]+/g, ""); }
|
|
119
120
|
/**
|
|
120
121
|
* 归一化索引:id 与别名都进同一张表,因此「精确 id」只是这张表的一个特例。
|
|
121
|
-
* 一个键可以指向多条路由(`zai` → 中国版 + 国际版,`ark` →
|
|
122
|
+
* 一个键可以指向多条路由(`zai` → 中国版 + 国际版,`ark` → 4 条)。
|
|
122
123
|
*/
|
|
123
124
|
const PROVIDER_LOOKUP = (() => {
|
|
124
125
|
const map = new Map();
|
|
@@ -139,7 +140,7 @@ const PROVIDER_LOOKUP = (() => {
|
|
|
139
140
|
* 1. 认不出的原串**原样返回**而不是丢掉——调用方要能拿到「这个名字不认识,可用的是……」才能
|
|
140
141
|
* 自我纠正;静默过滤只会让它以为本机没有额度。
|
|
141
142
|
* 2. 一个名字命中多条路由时**全部返回**,各自带着真实状态(没启用的会如实标 disabled)。
|
|
142
|
-
* 例如 `zai` 同时是中国版与国际版、`ark` 是
|
|
143
|
+
* 例如 `zai` 同时是中国版与国际版、`ark` 是 4 条路由:宁可多给几条数据让调用方自己判断,
|
|
143
144
|
* 也不替它猜一条,更不能返回空。
|
|
144
145
|
* @param requested 调用方给的原始名字列表。
|
|
145
146
|
*/
|
|
@@ -162,6 +163,9 @@ const VOLC_SECRET_KEY_ENV = "VOLC_SECRETKEY";
|
|
|
162
163
|
// 与 @mars-sea/dsh-commandcode-provider 一致的 CLI 版本标识(其常量 COMMAND_CODE_CLI_VERSION)。
|
|
163
164
|
const COMMAND_CODE_CLI_VERSION = "1.73.0";
|
|
164
165
|
const COMMAND_CODE_API_BASE = "https://api.commandcode.ai";
|
|
166
|
+
// 提供方插件(@mars-sea/dsh-commandcode-provider)注册的 settings namespace:账户列表、当前固定账户
|
|
167
|
+
// 与登记任务都在这里。Host 端只读它,不写(切换动作由 Client 走 settings remote)。
|
|
168
|
+
const COMMAND_CODE_SETTINGS_NS = "llm-commandcode";
|
|
165
169
|
const COMMAND_CODE_AUTH_FILE = join(homedir(), ".commandcode", "auth.json");
|
|
166
170
|
// 月总额快照与提供方插件 KNOWN_SUBSCRIPTION_PLANS 对齐(而非月剩余余额)。
|
|
167
171
|
// 来源:https://github.com/Mars-Sea/dsh-commandcode-provider/blob/main/src/capabilities.ts
|
|
@@ -189,7 +193,8 @@ function commandCodeAuthFileKey(io) {
|
|
|
189
193
|
}
|
|
190
194
|
// SuperGrok / X Premium 订阅(dsh-grok-kit 的 xai-oauth 路由):用量走官方 Grok CLI 的计费
|
|
191
195
|
// 代理(转发后端 GetGrokCreditsConfig)。凭据是 dsh-grok-kit / Grok CLI 共享登录文件里的
|
|
192
|
-
// OAuth Bearer
|
|
196
|
+
// OAuth Bearer(短命轮换):平时只读;token 过期时借 dsh-grok-kit 的官方会话链(getAuth)经同一
|
|
197
|
+
// 把写锁续约并写回同一文件,绝不自行裸 POST 刷新——后者才会顶掉两端的 refresh-token 轮换。
|
|
193
198
|
const GROK_API_BASE = "https://cli-chat-proxy.grok.com/v1";
|
|
194
199
|
// 官方 GrokComConfig.token_header 默认值:必须是该字符串,nginx 鉴权子请求才路由到 OAuth。
|
|
195
200
|
const GROK_TOKEN_AUTH = "xai-grok-cli";
|
|
@@ -197,6 +202,21 @@ const GROK_CLIENT_VERSION = "1.0.13";
|
|
|
197
202
|
const GROK_AUTH_FILE = join(homedir(), ".grok", "auth.json");
|
|
198
203
|
// dsh-grok-kit 旧版私存路径(其 resolveXaiOAuthStorePath 的回退分支)。
|
|
199
204
|
const XAI_OAUTH_AUTH_FILE = join(homedir(), ".dsh", ".xai-oauth-auth.json");
|
|
205
|
+
// dsh-grok-kit 是兄弟插件而非本插件依赖:动态加载,解析不到(未安装 / 开发期 link 布局)即降级。
|
|
206
|
+
// 0.2.0 起导出 XaiOAuthSession;其 models.getAuth("xai") 与 grok-kit 聊天/搜索走同一条官方 OAuth
|
|
207
|
+
// 刷新链——共享文件 store、写锁内二次检查与 compare-and-write,多个消费者并发也只刷新一次,
|
|
208
|
+
// 借它刷新不会顶掉 refresh-token 轮换(自己裸 POST 才会)。只缓存成功解析:失败(未安装、
|
|
209
|
+
// 旧版本)下次过期时重试,用户中途安装/升级 grok-kit 后无需重启 DSH。
|
|
210
|
+
let grokKitModule;
|
|
211
|
+
function loadGrokKit() {
|
|
212
|
+
if (grokKitModule) return Promise.resolve(grokKitModule);
|
|
213
|
+
return import("dsh-grok-kit")
|
|
214
|
+
.then(mod => {
|
|
215
|
+
if (mod && typeof mod.XaiOAuthSession === "function") grokKitModule = mod;
|
|
216
|
+
return grokKitModule;
|
|
217
|
+
})
|
|
218
|
+
.catch(() => undefined);
|
|
219
|
+
}
|
|
200
220
|
// Bearer 的 JWT payload 里有 sub(= user_id);登录文件缺 user_id 时兜底取它。
|
|
201
221
|
function bearerSubject(key) {
|
|
202
222
|
try {
|
|
@@ -411,9 +431,12 @@ function volcFailure(action, error) {
|
|
|
411
431
|
}
|
|
412
432
|
async function fetchJson(fetcher, url, headers, lifetimeSignal, init = {}) {
|
|
413
433
|
let response;
|
|
414
|
-
try { response = await fetcher(url, { method: init.method ?? "GET", ...(init.body !== undefined ? { body: init.body } : {}), headers: { accept: "application/json", "user-agent":
|
|
434
|
+
try { response = await fetcher(url, { method: init.method ?? "GET", ...(init.body !== undefined ? { body: init.body } : {}), headers: { accept: "application/json", "user-agent": `dsh-subusage/${VERSION}`, ...headers }, signal: lifetimeSignal ? AbortSignal.any([AbortSignal.timeout(10000), lifetimeSignal]) : AbortSignal.timeout(10000), redirect: "manual" }); }
|
|
415
435
|
catch { throw failure("subusage/network", "网络请求失败或超时", true); }
|
|
416
|
-
|
|
436
|
+
// 3xx(redirect: "manual" 下的域名迁移/跳转)不是鉴权失败,与 401/403 分开映射,
|
|
437
|
+
// 避免用户被误导去重登录;两个条件都加显式括号,不依赖 &&/|| 优先级。
|
|
438
|
+
if ((response.status >= 300 && response.status < 400)) throw failure("subusage/response", "服务端返回了意外的重定向响应(HTTP 3xx)");
|
|
439
|
+
if ((response.status === 401 || response.status === 403)) throw failure("subusage/auth", "登录或凭据已失效");
|
|
417
440
|
if (!response.ok) {
|
|
418
441
|
const retry = response.status === 408 || response.status === 429 || response.status >= 500;
|
|
419
442
|
const raw = response.headers?.get?.("retry-after");
|
|
@@ -880,6 +903,23 @@ export const subUsageRemote = {
|
|
|
880
903
|
]
|
|
881
904
|
};
|
|
882
905
|
|
|
906
|
+
/**
|
|
907
|
+
* 某条 provider 的缓存键。Command Code 是多账户的:额外账户(凭据引用名)各占一份缓存,
|
|
908
|
+
* 否则账户 A 的结果会被账户 B 顶掉——每读一次全部账户就把前面几家的缓存驱逐一遍,
|
|
909
|
+
* 60 秒 TTL 形同虚设。默认账户(""/"default")与其它 provider 仍按 id 单键。
|
|
910
|
+
*/
|
|
911
|
+
function providerCacheKey(id, account = "") {
|
|
912
|
+
return id === "commandcode" && account !== "" && account !== "default" ? `${id}@${account}` : id;
|
|
913
|
+
}
|
|
914
|
+
/**
|
|
915
|
+
* 把 no-key 文案里的**凭据引用名**(`apiKeyEnv`)换成账户标签。
|
|
916
|
+
* 对外额度视图承诺不返回继承变量名/凭据引用,而错误文案里嵌了引用名(「...无引用 COMMANDCODE_ACCOUNT_WORK」);
|
|
917
|
+
* Client 通道(read/refresh)**保留**引用名——那是本机界面,用户要靠它定位该往哪个 ref 里写凭据。
|
|
918
|
+
*/
|
|
919
|
+
function redactAccountRef(message, ref, label) {
|
|
920
|
+
if (typeof message !== "string" || typeof ref !== "string" || ref === "" || !message.includes(ref)) return message;
|
|
921
|
+
return message.split(ref).join(label);
|
|
922
|
+
}
|
|
883
923
|
// 可注入仅供隔离测试;生产仍由当前 manifest 的 Cordis ctx.plugin 注册。
|
|
884
924
|
export class SubUsageService extends TypertRemoteService {
|
|
885
925
|
constructor(ctx, options = {}) {
|
|
@@ -899,9 +939,16 @@ export class SubUsageService extends TypertRemoteService {
|
|
|
899
939
|
// 默认账户仍走上面的 envName 链(凭据服务 → 启动环境 → ~/.commandcode/auth.json)。
|
|
900
940
|
this.accountCredentials = options.resolveAccountCredentials ?? (async ref => await ctx.get("credentials")?.resolve(credentialRef(ref)));
|
|
901
941
|
this.accountEnvironment = options.resolveAccountEnvironment ?? (ref => launchEnvironmentOf(ctx).get(ref));
|
|
902
|
-
|
|
942
|
+
// Command Code 多账户:账户列表与当前固定账户由提供方插件写在它自己的 settings namespace
|
|
943
|
+
// (llm-commandcode)里,Host 端经 settings 服务读取。服务未挂载(提供方插件未运行)时读不到,
|
|
944
|
+
// 工具退回单账户模式;options.settingsService 供隔离测试注入。
|
|
945
|
+
this.settingsService = options.settingsService;
|
|
946
|
+
try { ctx.inject?.(["settings"], (scope) => { if (scope.settings !== undefined) this.settingsService = scope.settings; }); } catch { /* settings 服务不可用:保持单账户模式 */ }
|
|
947
|
+
this.authFile = options.resolveAuthFile ?? (async () => commandCodeAuthFileKey(this.io));
|
|
903
948
|
// SuperGrok 只读共享登录文件(~/.grok/auth.json 等)取 OAuth Bearer;可注入仅供隔离测试。
|
|
904
949
|
this.grokAuth = options.resolveGrokAuth ?? (async () => grokAuthFileCredentials(this.io));
|
|
950
|
+
// 过期时借 dsh-grok-kit 的官方会话链刷新同一登录文件;解析不到兄弟插件时返回 undefined 并降级。
|
|
951
|
+
this.resolveGrokKit = options.resolveGrokKit ?? loadGrokKit;
|
|
905
952
|
// Codex 只读共享登录文件($CODEX_HOME/auth.json 或 ~/.codex/auth.json)取 OAuth access token。
|
|
906
953
|
this.codexAuth = options.resolveCodexAuth ?? (async () => codexAuthFileCredentials(this.io, { CODEX_HOME: (await this.environmentByName("CODEX_HOME"))?.value }));
|
|
907
954
|
this.cache = new Map(); this.inflight = new Map(); this.generations = new Map(); this.fingerprints = new Map(); this.disposed = false; this.controller = new AbortController();
|
|
@@ -973,6 +1020,33 @@ export class SubUsageService extends TypertRemoteService {
|
|
|
973
1020
|
if (typeof envAk?.value === "string" && envAk.value.trim() && typeof envSk?.value === "string" && envSk.value.trim()) return { key: envAk.value.trim(), secret: envSk.value.trim(), source: "env" };
|
|
974
1021
|
return ak && sk ? { key: ak, secret: sk, source: "manual" } : { key: undefined, secret: undefined, source: "none" };
|
|
975
1022
|
}
|
|
1023
|
+
/**
|
|
1024
|
+
* Grok 登录文件的 access token 过期时,借 dsh-grok-kit 的官方会话链续约:getAuth("xai")
|
|
1025
|
+
* 在共享凭据 store 的写锁内二次检查并刷新,新 token 由官方链写回同一个 ~/.grok/auth.json,
|
|
1026
|
+
* 与 grok-kit 聊天/搜索/Grok CLI 是同一套锁协议,不会顶掉 refresh-token 轮换。
|
|
1027
|
+
* 不直接信任 getAuth 返回值:无论刷新由本进程还是并发的另一进程完成,一律重读登录文件拿新 token 与新过期时间。
|
|
1028
|
+
* 回到 { renewed:true, identity };兄弟插件不可用时 unavailable:true;刷新尝试失败时带 refreshError。
|
|
1029
|
+
*/
|
|
1030
|
+
async renewGrokIdentity(identity, signal) {
|
|
1031
|
+
if (!identity.expiresAt || Date.parse(identity.expiresAt) > this.now()) return { renewed: false, unavailable: true, identity };
|
|
1032
|
+
let kit;
|
|
1033
|
+
try { kit = await this.resolveGrokKit(); } catch { kit = undefined; }
|
|
1034
|
+
if (!kit?.XaiOAuthSession) return { renewed: false, unavailable: true, identity };
|
|
1035
|
+
let session;
|
|
1036
|
+
let refreshError;
|
|
1037
|
+
try {
|
|
1038
|
+
session = new kit.XaiOAuthSession();
|
|
1039
|
+
// pi-ai 内部有 15s 刷新超时与 2s 文件锁预算;外层再兜 20s 总预算,避免挂死药丸/工具刷新。
|
|
1040
|
+
const deadline = AbortSignal.timeout(20000);
|
|
1041
|
+
await session.models.getAuth("xai", { signal: signal ? AbortSignal.any([signal, deadline]) : deadline });
|
|
1042
|
+
} catch (error) { refreshError = error; }
|
|
1043
|
+
finally { try { session?.dispose?.(); } catch { /* 会话只持有进程内模型缓存,dispose 失败无后果 */ } }
|
|
1044
|
+
const cred = await this.grokAuth();
|
|
1045
|
+
if (cred?.key && cred.key !== identity.key && (!cred.expiresAt || Date.parse(cred.expiresAt) > this.now())) {
|
|
1046
|
+
return { renewed: true, identity: { key: cred.key, source: "auth-file", ...(cred.userId ? { userId: cred.userId } : {}), ...(cred.expiresAt ? { expiresAt: cred.expiresAt } : {}) } };
|
|
1047
|
+
}
|
|
1048
|
+
return { renewed: false, unavailable: false, refreshError, identity };
|
|
1049
|
+
}
|
|
976
1050
|
async fetchProvider(id, identity, settings, signal = this.controller.signal) {
|
|
977
1051
|
const get = (url, headers, init) => fetchJson(this.fetcher, url, headers, signal, init);
|
|
978
1052
|
// 火山方舟额度走管控面 OpenAPI:POST 根路径 + Action/Version 在 query,鉴权是 AK/SK 签名。
|
|
@@ -1026,7 +1100,7 @@ export class SubUsageService extends TypertRemoteService {
|
|
|
1026
1100
|
// HTTP 200 但窗口为空是「未订阅」,不是 0% 用量:显式说明,不画零额度的假象。
|
|
1027
1101
|
else if (!windows.length) extras.push({ kind: "plan", value: parsed.subscribed ? "本周期暂无窗口数据" : "未检测到该套餐订阅" });
|
|
1028
1102
|
// 订阅状态要**结构化**带出去,不能只写进 extras 文案:同一组 AK/SK 能查到名下所有套餐,
|
|
1029
|
-
// 界面上若把
|
|
1103
|
+
// 界面上若把 4 条路由一律列出来,用户根本分不清哪个是哪个。前端据此收起没订阅的那些。
|
|
1030
1104
|
return { windows, extras, coverage: windows.length ? "complete" : "partial", subscribed: parsed.subscribed === true };
|
|
1031
1105
|
}
|
|
1032
1106
|
const headers = { authorization: `Bearer ${identity.key}` };
|
|
@@ -1099,17 +1173,25 @@ export class SubUsageService extends TypertRemoteService {
|
|
|
1099
1173
|
return normalizeCommandCode(credits, subscription);
|
|
1100
1174
|
}
|
|
1101
1175
|
if (id === "xai-oauth") {
|
|
1102
|
-
// 登录文件里的 access token 短命(实测
|
|
1103
|
-
//
|
|
1104
|
-
//
|
|
1105
|
-
//
|
|
1106
|
-
|
|
1107
|
-
|
|
1176
|
+
// 登录文件里的 access token 短命(实测 6 小时量级),而 grok-kit 的自动刷新是懒触发:
|
|
1177
|
+
// 只有它自己聊天/搜索遇到过期才刷,没有后台定时器。本插件每分钟读一次用量,是最常发现
|
|
1178
|
+
// 过期的一方——若只读不刷,就会出现「Grok Kit 显示已登录、用量卡片显示已过期」的割裂。
|
|
1179
|
+
// 过期时借 grok-kit 的官方会话链续约(同一文件、同一把写锁、compare-and-write,不顶轮换);
|
|
1180
|
+
// 兄弟插件不可用或刷新失败时,才退回可行动的手动提示。
|
|
1181
|
+
let grok = identity;
|
|
1182
|
+
if (grok.expiresAt && Date.parse(grok.expiresAt) <= this.now()) {
|
|
1183
|
+
const outcome = await this.renewGrokIdentity(grok, signal);
|
|
1184
|
+
if (outcome.renewed) grok = outcome.identity;
|
|
1185
|
+
else if (outcome.unavailable) throw failure("subusage/auth", `Grok 登录文件的 access token 已于 ${new Date(identity.expiresAt).toLocaleString("zh-CN")} 过期,但本机未能加载 dsh-grok-kit(≥0.2.0)的自动刷新链。用一次 Grok CLI(登录后发一条消息),或在 设置 → xAI Grok 里重新登录,都会刷新同一个文件(${GROK_AUTH_FILE})。`);
|
|
1186
|
+
else {
|
|
1187
|
+
const reason = outcome.refreshError?.message ? String(outcome.refreshError.message).slice(0, 200) : "登录刷新链未返回新凭据";
|
|
1188
|
+
throw failure("subusage/auth", `Grok 登录已于 ${new Date(identity.expiresAt).toLocaleString("zh-CN")} 过期,尝试经 dsh-grok-kit 自动刷新失败:${reason}。通常需要重新登录:在 设置 → xAI Grok 重新登录,或运行 grok login(同一登录文件 ${GROK_AUTH_FILE})。`);
|
|
1189
|
+
}
|
|
1108
1190
|
}
|
|
1109
1191
|
// 直连官方 Grok CLI 计费代理;X-XAI-Token-Auth 的取值是 token_header 字符串(xai-grok-cli)
|
|
1110
1192
|
// 而非布尔,x-userid 取登录文件。套餐名走 /v1/settings(subscription_tier_display),
|
|
1111
1193
|
// 失败不阻塞用量(回退 billing 响应里的 subscription_tier)。
|
|
1112
|
-
const grokHeaders = {
|
|
1194
|
+
const grokHeaders = { authorization: `Bearer ${grok.key}`, "x-xai-token-auth": GROK_TOKEN_AUTH, "x-grok-client-version": GROK_CLIENT_VERSION, ...(grok.userId ? { "x-userid": grok.userId } : {}) };
|
|
1113
1195
|
const [billing, settings] = await Promise.all([
|
|
1114
1196
|
get(`${GROK_API_BASE}/billing?format=credits`, grokHeaders),
|
|
1115
1197
|
get(`${GROK_API_BASE}/settings`, grokHeaders).catch(() => undefined)
|
|
@@ -1129,6 +1211,93 @@ export class SubUsageService extends TypertRemoteService {
|
|
|
1129
1211
|
if (usageError) data.usageError = usageError;
|
|
1130
1212
|
return data;
|
|
1131
1213
|
}
|
|
1214
|
+
/**
|
|
1215
|
+
* Command Code 的多账户清单与当前激活项,读自提供方插件的 settings namespace(llm-commandcode)。
|
|
1216
|
+
* id/label 规则与 Client 的 commandCodeAccountList 完全一致:默认账户固定 id "default"(label 用人读
|
|
1217
|
+
* 文案),额外账户 id 取其凭据引用名(apiKeyEnv)、label 空白回退 `Account N`;无凭据引用与登记未完成
|
|
1218
|
+
* (phase 非 naming)的账户不列。
|
|
1219
|
+
* 与提供方插件 slots() 的**唯一差异**:它额外支持 `accounts[].apiKey` 字面 Key 账户(id `account-N`),
|
|
1220
|
+
* 本插件**不支持**——那种 Key 只存在提供方自己的配置里,按引用名解析不到(identity() 也没有字面 Key
|
|
1221
|
+
* 分支),列出来只会选中即 no-key。Client 侧同样跳过,两端口径因此保持一致。
|
|
1222
|
+
* activeAccount 为空、为 "auto" 或指向已消失的账户时都按**自动轮换**处理(与其 preferredId 同义)。
|
|
1223
|
+
* 读不到(settings 服务未挂载 / namespace 不存在)返回 null,调用方退回单账户模式。
|
|
1224
|
+
*/
|
|
1225
|
+
commandCodeAccountPlan() {
|
|
1226
|
+
try {
|
|
1227
|
+
// redactSecrets:本插件只读这几个非秘密字段,但提供方配置里可能存在手写的字面 apiKey
|
|
1228
|
+
// (role "secret");带上它就只剥离秘密字段(apiKeyEnv 是 credential-ref,不受影响),
|
|
1229
|
+
// 字面 Key 不会进到本插件的内存里。
|
|
1230
|
+
const rows = this.settingsService?.describe?.({ redactSecrets: true });
|
|
1231
|
+
const row = Array.isArray(rows) ? rows.find(item => item?.ns === COMMAND_CODE_SETTINGS_NS) : undefined;
|
|
1232
|
+
if (!row) return null;
|
|
1233
|
+
const value = row.value && typeof row.value === "object" ? row.value : {};
|
|
1234
|
+
const tasks = Array.isArray(value.accountEnrollmentTasks) ? value.accountEnrollmentTasks : [];
|
|
1235
|
+
const accounts = [{ id: "default", label: "默认账户" }];
|
|
1236
|
+
const seen = new Set(["default"]);
|
|
1237
|
+
for (const [index, account] of (Array.isArray(value.accounts) ? value.accounts : []).entries()) {
|
|
1238
|
+
const ref = typeof account?.apiKeyEnv === "string" && account.apiKeyEnv.trim() !== "" ? account.apiKeyEnv.trim() : void 0;
|
|
1239
|
+
if (ref === void 0) continue;
|
|
1240
|
+
// 同一个引用名出现两次(配置被手改过)时只列一次:否则同账户会出两行、还会多打一份计费请求。
|
|
1241
|
+
if (seen.has(ref)) continue;
|
|
1242
|
+
if (tasks.some(task => task?.ref === ref && task.phase !== "naming")) continue;
|
|
1243
|
+
seen.add(ref);
|
|
1244
|
+
accounts.push({ id: ref, label: typeof account.label === "string" && account.label.trim() !== "" ? account.label.trim() : `Account ${index + 2}` });
|
|
1245
|
+
}
|
|
1246
|
+
const raw = typeof value.activeAccount === "string" ? value.activeAccount.trim() : "";
|
|
1247
|
+
const activeId = raw !== "" && accounts.some(account => account.id === raw) ? raw : "";
|
|
1248
|
+
return { mode: activeId === "" ? "auto" : "fixed", activeId, accounts: accounts.map(account => ({ ...account, active: account.id === activeId })) };
|
|
1249
|
+
} catch { return null; }
|
|
1250
|
+
}
|
|
1251
|
+
/**
|
|
1252
|
+
* 自动轮换模式下的**轮换池健康摘要**:UI 通道(read/refresh)默认只回顶层 Key 单账户,
|
|
1253
|
+
* 用户看到的是乐观数字,但实际请求会落到池中任意账户——默认账户健康不代表池健康
|
|
1254
|
+
* (其余账户可能已全部 5h 限额)。这里复用各账户自己的缓存并行读一遍,把摘要挂到默认
|
|
1255
|
+
* 账户 entry 上,Client 仅在 auto 模式渲染。固定模式(activeId 非空)返回 null。
|
|
1256
|
+
*
|
|
1257
|
+
* 业务口径(不能简单取「池中最紧张账户」):各账户是**独立配额**,轮换器会避开已限额的
|
|
1258
|
+
* 账户,所以单账户限额不是立即失败信号。只有「所有有凭据的账户都限额」才是池耗尽:
|
|
1259
|
+
* healthy = ok 且没有 rate-limited 窗口
|
|
1260
|
+
* blocked = ok 但至少一个窗口 rate-limited(容量降级,仍可能成功)
|
|
1261
|
+
* unusable = no-key/no-cookie/disabled/error(读不到或没凭据,不计入有效容量)
|
|
1262
|
+
* 池里没有任何有凭据的账户时 exhausted=false(那是「未配置」,由默认 entry 的 no-key 表达)。
|
|
1263
|
+
*/
|
|
1264
|
+
async commandCodePool(settings, force, defaultEntry) {
|
|
1265
|
+
const plan = this.commandCodeAccountPlan();
|
|
1266
|
+
if (!plan || plan.mode !== "auto") return null;
|
|
1267
|
+
// 默认账户那条 entry 已由调用方读过(避免重复一发),其余账户各自命中 provider() 的 TTL/
|
|
1268
|
+
// inflight 缓存,因此每分钟的自动刷新不会真的打 N 次计费接口。
|
|
1269
|
+
const readOne = async (account) => {
|
|
1270
|
+
if (account.id === "default") return defaultEntry;
|
|
1271
|
+
try { return await this.provider("commandcode", settings, force, account.id); }
|
|
1272
|
+
catch { return { ...this.base("commandcode", "none", settings, account.id), state: "error", errorCode: "subusage/internal", error: "读取用量失败", windows: [], extras: [] }; }
|
|
1273
|
+
};
|
|
1274
|
+
const entries = await Promise.all(plan.accounts.map(account => readOne(account)));
|
|
1275
|
+
const accounts = plan.accounts.map((account, index) => {
|
|
1276
|
+
const entry = entries[index];
|
|
1277
|
+
const windows = Array.isArray(entry?.windows) ? entry.windows : [];
|
|
1278
|
+
const ok = entry?.state === "ok";
|
|
1279
|
+
return {
|
|
1280
|
+
id: account.id, label: account.label, active: account.active === true,
|
|
1281
|
+
state: entry?.state ?? "error", errorCode: entry?.errorCode, windows,
|
|
1282
|
+
// usable:读到了且没有任何限额窗口;blocked:读到了但有窗口限额(容量降级,仍可能成功)。
|
|
1283
|
+
usable: ok && !windows.some(w => w.status === "rate-limited"),
|
|
1284
|
+
blocked: ok && windows.some(w => w.status === "rate-limited")
|
|
1285
|
+
};
|
|
1286
|
+
});
|
|
1287
|
+
// 「有效容量」= usable + blocked(真的有凭据且读到了额度的账户);no-key/error/disabled 不算。
|
|
1288
|
+
const capable = accounts.filter(a => a.usable || a.blocked);
|
|
1289
|
+
const available = accounts.filter(a => a.usable).length;
|
|
1290
|
+
const blockedCount = accounts.filter(a => a.blocked).length;
|
|
1291
|
+
const unusableCount = accounts.length - available - blockedCount;
|
|
1292
|
+
const exhausted = capable.length > 0 && capable.every(a => a.blocked);
|
|
1293
|
+
return { mode: "auto", total: accounts.length, available, blockedCount, unusableCount, exhausted, accounts };
|
|
1294
|
+
}
|
|
1295
|
+
/** 按 id 失效一家 provider 的**全部账户**缓存:凭据/开关变化不能只影响默认账户那一条。 */
|
|
1296
|
+
invalidateProvider(id) {
|
|
1297
|
+
const keys = new Set([id]);
|
|
1298
|
+
for (const map of [this.cache, this.inflight, this.fingerprints, this.generations]) for (const key of map.keys()) if (key.startsWith(`${id}@`)) keys.add(key);
|
|
1299
|
+
for (const key of keys) { this.generations.set(key, (this.generations.get(key) ?? 0) + 1); this.cache.delete(key); this.inflight.delete(key); }
|
|
1300
|
+
}
|
|
1132
1301
|
base(id, source, settings, account = "") {
|
|
1133
1302
|
// MiMo 会话 Cookie 自签发起 24 小时有效;到期时间随每条 entry 下发,供药丸与设置页倒计时。
|
|
1134
1303
|
const expiresAt = id === "xiaomi-token-plan-cn" ? isoStamp(settings?.xiaomi?.expiresAt) : null;
|
|
@@ -1137,7 +1306,10 @@ export class SubUsageService extends TypertRemoteService {
|
|
|
1137
1306
|
return { providerId: id, label: PROVIDERS[id].label, envName: PROVIDERS[id].envName, keySource: source, apiDetected: source === "none" ? null : true, coverage: "partial", freshness: "unknown", lastSuccessAt: null, lastAttemptAt: new Date(this.now()).toISOString(), retryable: false, retainPrevious: false, ...shown, ...(expiresAt ? { cookieExpiresAt: expiresAt } : {}) };
|
|
1138
1307
|
}
|
|
1139
1308
|
async provider(id, settings, force, account = "") {
|
|
1140
|
-
|
|
1309
|
+
// 缓存/inflight/fingerprint 都按**账户**分键:Command Code 的多账户各占一份,
|
|
1310
|
+
// 读取全部账户时不会互相驱逐缓存(见 providerCacheKey)。
|
|
1311
|
+
const cacheKey = providerCacheKey(id, account);
|
|
1312
|
+
const startedGeneration = this.generations.get(cacheKey) ?? 0;
|
|
1141
1313
|
let identity;
|
|
1142
1314
|
// identity 解析依赖凭据服务/登录文件读取,可能像 HTTP 一样永久挂起:套上与 fetchJson 同量级
|
|
1143
1315
|
// 的超时。超时不等于凭据失效——不清 Cookie、不用 subusage/credentials(client 的
|
|
@@ -1147,25 +1319,25 @@ export class SubUsageService extends TypertRemoteService {
|
|
|
1147
1319
|
try { identity = await withTimeout(this.identity(id, settings, account), this.identityTimeoutMs, () => failure("subusage/identity-timeout", `凭据解析超时(${Math.round(this.identityTimeoutMs / 1000)} 秒无响应):凭据服务或登录文件读取未返回,请稍后重试`)); }
|
|
1148
1320
|
catch (e) {
|
|
1149
1321
|
const timedOut = e?.code === "subusage/identity-timeout";
|
|
1150
|
-
this.cache.delete(
|
|
1322
|
+
this.cache.delete(cacheKey);
|
|
1151
1323
|
return { ...this.base(id, "none", settings, account), state: "error", errorCode: timedOut ? "subusage/identity-timeout" : "subusage/credentials", error: timedOut ? e.message : "无法解析凭据" };
|
|
1152
1324
|
}
|
|
1153
1325
|
if (this.disposed) throw failure("subusage/disposed", "服务已卸载");
|
|
1154
|
-
if (startedGeneration !== (this.generations.get(
|
|
1326
|
+
if (startedGeneration !== (this.generations.get(cacheKey) ?? 0)) return this.provider(id, this.load(), force, account);
|
|
1155
1327
|
if (!providerEnabled(settings, id)) {
|
|
1156
|
-
this.cache.delete(
|
|
1328
|
+
this.cache.delete(cacheKey); this.inflight.delete(cacheKey);
|
|
1157
1329
|
return { ...this.base(id, identity.source, settings, account), state: "disabled", apiDetected: !!identity.key, windows: [], extras: [] };
|
|
1158
1330
|
}
|
|
1159
1331
|
// fingerprint 计入账户选择:切换账户后同 id 缓存/inflight 不串账(不同账户 key 必不同);
|
|
1160
1332
|
// 空串与 "default" 同为默认账户,归一后共享缓存。
|
|
1161
1333
|
const fingerprint = hash([identity.key ?? "", identity.secret ?? "", identity.source, settings.keyModes[id], id === "zai-coding-cn" ? settings.zai : null, id === "commandcode" ? (account && account !== "default" ? account : "default") : null]);
|
|
1162
|
-
if (this.fingerprints.has(
|
|
1163
|
-
this.fingerprints.set(
|
|
1164
|
-
const generation = this.generations.get(
|
|
1165
|
-
const cached = this.cache.get(
|
|
1166
|
-
if (cached && cached.fingerprint !== fingerprint) this.cache.delete(
|
|
1334
|
+
if (this.fingerprints.has(cacheKey) && this.fingerprints.get(cacheKey) !== fingerprint) this.generations.set(cacheKey, (this.generations.get(cacheKey) ?? 0) + 1);
|
|
1335
|
+
this.fingerprints.set(cacheKey, fingerprint);
|
|
1336
|
+
const generation = this.generations.get(cacheKey) ?? 0;
|
|
1337
|
+
const cached = this.cache.get(cacheKey);
|
|
1338
|
+
if (cached && cached.fingerprint !== fingerprint) this.cache.delete(cacheKey);
|
|
1167
1339
|
const previous = cached?.fingerprint === fingerprint ? cached : undefined;
|
|
1168
|
-
const active = this.inflight.get(
|
|
1340
|
+
const active = this.inflight.get(cacheKey);
|
|
1169
1341
|
if (active?.fingerprint === fingerprint && active.generation === generation) return active.promise;
|
|
1170
1342
|
const now = this.now();
|
|
1171
1343
|
const resetDue = previous?.entry.windows?.some(w => Number.isFinite(Date.parse(w.resetsAt)) && Date.parse(w.resetsAt) > previous.at && Date.parse(w.resetsAt) <= now);
|
|
@@ -1187,12 +1359,12 @@ export class SubUsageService extends TypertRemoteService {
|
|
|
1187
1359
|
failures++;
|
|
1188
1360
|
if (temporary) retryAt = this.now() + Math.max(e.details?.retryAfterMs ?? 0, Math.min(300000, 1000 * 2 ** Math.min(failures, 8)));
|
|
1189
1361
|
}
|
|
1190
|
-
if (!this.disposed && generation === (this.generations.get(
|
|
1191
|
-
if (this.disposed || generation !== (this.generations.get(
|
|
1362
|
+
if (!this.disposed && generation === (this.generations.get(cacheKey) ?? 0)) this.cache.set(cacheKey, { fingerprint, entry, at: this.now(), retryAt, failures });
|
|
1363
|
+
if (this.disposed || generation !== (this.generations.get(cacheKey) ?? 0)) return { ...this.base(id, "none", settings, account), state: "error", errorCode: "subusage/config-changed", error: "读取期间配置已改变,请刷新" };
|
|
1192
1364
|
return entry;
|
|
1193
1365
|
})();
|
|
1194
|
-
this.inflight.set(
|
|
1195
|
-
try { return await promise; } finally { if (this.inflight.get(
|
|
1366
|
+
this.inflight.set(cacheKey, { fingerprint, generation, promise });
|
|
1367
|
+
try { return await promise; } finally { if (this.inflight.get(cacheKey)?.promise === promise) this.inflight.delete(cacheKey); }
|
|
1196
1368
|
}
|
|
1197
1369
|
// 对外只读额度视图:供其他插件(ctx.get("subUsage").quota(...))与 subusage_quota 工具使用。
|
|
1198
1370
|
// **刻意不返回 settings,也不返回 keySource / apiDetected**:第三方消费者只需要额度,
|
|
@@ -1207,7 +1379,18 @@ export class SubUsageService extends TypertRemoteService {
|
|
|
1207
1379
|
const wanted = matched.length || !unknown.length ? matched : [...IDS];
|
|
1208
1380
|
const settings = this.load();
|
|
1209
1381
|
const force = request?.force === true;
|
|
1210
|
-
|
|
1382
|
+
// Command Code 是多账户的:一次把**每个账户**都读一遍,并标出当前激活的是哪一个(或自动轮换)。
|
|
1383
|
+
// 账户清单来自提供方插件的 settings namespace(llm-commandcode);读不到时退回单账户模式。
|
|
1384
|
+
const plan = wanted.includes("commandcode") ? this.commandCodeAccountPlan() : null;
|
|
1385
|
+
// 激活账户排最前:调用方第一眼要看的就是「现在实际在用的是哪一个」。
|
|
1386
|
+
const planAccounts = plan ? (plan.mode === "fixed" ? [...plan.accounts].sort((a, b) => Number(b.active) - Number(a.active)) : plan.accounts) : null;
|
|
1387
|
+
const read = (id, account = "") => this.provider(id, settings, force, account).catch(() => ({ ...this.base(id, "none", settings, account), state: "error", errorCode: "subusage/internal", error: "读取用量失败" }));
|
|
1388
|
+
const tasks = [];
|
|
1389
|
+
for (const id of wanted) {
|
|
1390
|
+
if (planAccounts && id === "commandcode") tasks.push(...planAccounts.map(account => read(id, account.id)));
|
|
1391
|
+
else tasks.push(read(id));
|
|
1392
|
+
}
|
|
1393
|
+
const entries = await withTimeout(Promise.all(tasks), 30000, () => failure("subusage/timeout", "额度查询超过 30 秒仍未完成,请稍后重试或缩小 providers"));
|
|
1211
1394
|
// 认不出的名字要回一条**可读**条目而不是静默丢掉:调用方(尤其是模型)无从知道本插件用的是
|
|
1212
1395
|
// 路由 id,丢掉之后它只看到空列表,会当成「本机没配任何额度」——真正的原因只是名字没对上。
|
|
1213
1396
|
// 放在最前面,免得淹没在几十条数据后面。
|
|
@@ -1217,19 +1400,28 @@ export class SubUsageService extends TypertRemoteService {
|
|
|
1217
1400
|
}));
|
|
1218
1401
|
return {
|
|
1219
1402
|
updatedAt: new Date(this.now()).toISOString(),
|
|
1220
|
-
providers: [...unknownEntries, ...entries.map(entry =>
|
|
1221
|
-
providerId
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1403
|
+
providers: [...unknownEntries, ...entries.map(entry => {
|
|
1404
|
+
const account = planAccounts && entry.providerId === "commandcode" ? planAccounts.find(item => item.id === (entry.account ?? "default")) : undefined;
|
|
1405
|
+
const extras = Array.isArray(entry.extras) ? entry.extras : [];
|
|
1406
|
+
return {
|
|
1407
|
+
providerId: entry.providerId,
|
|
1408
|
+
label: entry.label,
|
|
1409
|
+
state: entry.state,
|
|
1410
|
+
windows: Array.isArray(entry.windows) ? entry.windows : [],
|
|
1411
|
+
// 账户标注进 extras:单独读 JSON 也能知道这条数字属于哪个账户(文本层把它并进标题行)。
|
|
1412
|
+
extras: account ? [...extras.filter(item => item?.kind !== "account"), { kind: "account", value: account.label }] : extras,
|
|
1413
|
+
// 激活状态是「现在实际用哪一个」的唯一答案:固定账户给 accountActive,自动轮换给 accountMode=auto。
|
|
1414
|
+
...(account ? { account: account.label, accountActive: account.active === true, accountMode: plan.mode } : {}),
|
|
1415
|
+
// 只输出真正有值的字段:视图里带 `undefined` 的键会被调用方的 schema 校验判成型别错误
|
|
1416
|
+
// (DSH 就会),而且「字段缺席」本来就该表示「这家没报告」,不是「报告了空」。
|
|
1417
|
+
...(typeof entry.coverage === "string" ? { coverage: entry.coverage } : {}),
|
|
1418
|
+
...(typeof entry.freshness === "string" ? { freshness: entry.freshness } : {}),
|
|
1419
|
+
...(typeof entry.lastSuccessAt === "string" ? { lastSuccessAt: entry.lastSuccessAt } : {}),
|
|
1420
|
+
// 对外视图里不能出现内部凭据引用名:no-key 文案嵌了它,这里换成账户标签
|
|
1421
|
+
// (client 通道仍保留引用名——那是本机排障要用的定位信息)。
|
|
1422
|
+
...(entry.error ? { error: account ? redactAccountRef(entry.error, account.id, account.label) : entry.error } : {})
|
|
1423
|
+
};
|
|
1424
|
+
})]
|
|
1233
1425
|
};
|
|
1234
1426
|
}
|
|
1235
1427
|
async read(request) { return this.refresh(request && request.providerIds ? request : { providerIds: IDS, force: false }); }
|
|
@@ -1242,6 +1434,16 @@ export class SubUsageService extends TypertRemoteService {
|
|
|
1242
1434
|
const unknownEntries = (query.unknown ?? []).map(id => ({ providerId: id, label: id, state: "error", errorCode: "subusage/unknown-provider", error: "本机运行中的 Host 版本较旧,不认识该提供商;重启 DSH 后生效", apiDetected: null, coverage: "partial", freshness: "unknown", keySource: "none", retryable: false, retainPrevious: false, windows: [], extras: [] }));
|
|
1243
1435
|
const latest = this.load();
|
|
1244
1436
|
if (hash(latest) !== hash(settings)) return this.result(latest, [...query.providerIds.map(id => ({ ...this.base(id, "none", latest, query.commandCodeAccount), state: "error", errorCode: "subusage/config-changed", error: "读取期间配置已改变,请刷新" })), ...unknownEntries]);
|
|
1437
|
+
// Command Code 自动轮换:把轮换池健康摘要挂到默认账户(顶层 Key)entry 上。
|
|
1438
|
+
// 仅当本次查询的就是默认账户(commandCodeAccount 为空)时——查某个额外账户时不挂,
|
|
1439
|
+
// 那条 entry 代表的是被点名的账户本身。提供方插件未运行 / 固定账户时 pool 为 null,原样返回。
|
|
1440
|
+
if (query.providerIds.includes("commandcode") && (!query.commandCodeAccount || query.commandCodeAccount === "default")) {
|
|
1441
|
+
const ccIndex = entries.findIndex(e => e && e.providerId === "commandcode");
|
|
1442
|
+
if (ccIndex >= 0) {
|
|
1443
|
+
const pool = await this.commandCodePool(latest, query.force, entries[ccIndex]);
|
|
1444
|
+
if (pool) entries[ccIndex] = { ...entries[ccIndex], accountPool: pool };
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1245
1447
|
return this.result(latest, [...entries, ...unknownEntries]);
|
|
1246
1448
|
}
|
|
1247
1449
|
startMimoLogin(request) { return this.login.start(parseLoginStart(request)); } getMimoLoginStatus() { return this.login.status(); }
|
|
@@ -1301,10 +1503,10 @@ export class SubUsageService extends TypertRemoteService {
|
|
|
1301
1503
|
updateVolc("secretAccessKey", patch.secretKeyUpdate);
|
|
1302
1504
|
try { this.io.mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 }); this.io.writeFileSync(`${this.path}.tmp`, JSON.stringify(stored, null, 2), { encoding: "utf8", mode: 0o600 }); this.io.chmodSync(`${this.path}.tmp`, 0o600); this.io.renameSync(`${this.path}.tmp`, this.path); }
|
|
1303
1505
|
catch { throw failure("subusage/config", "无法保存订阅设置"); }
|
|
1304
|
-
for (const
|
|
1506
|
+
for (const id of affected) this.invalidateProvider(id);
|
|
1305
1507
|
return this.result(stored, []);
|
|
1306
1508
|
}
|
|
1307
|
-
dispose() { this.login.dispose(); this.disposed = true; this.controller.abort(); this.cache.clear(); this.inflight.clear(); this.fingerprints.clear(); for (const
|
|
1509
|
+
dispose() { this.login.dispose(); this.disposed = true; this.controller.abort(); this.cache.clear(); this.inflight.clear(); this.fingerprints.clear(); for (const key of [...this.generations.keys(), ...IDS]) this.generations.set(key, (this.generations.get(key) ?? 0) + 1); }
|
|
1308
1510
|
}
|
|
1309
1511
|
function registerRemotes(ctx) {
|
|
1310
1512
|
ctx.inject(["typert"], scope => { scope.effect(() => scope.typert.register({ package: subUsageRemote.package, face: "host", schemas: [], model: { services: [], events: [], objects: [] }, invocations: subUsageRemote.descriptors })); });
|
|
@@ -1314,12 +1516,20 @@ function apply(ctx) { registerRemotes(ctx); ctx.plugin(SubUsageService, {}); reg
|
|
|
1314
1516
|
* 工具结果的人读文本:一行一个 provider,先给窗口再给余额,异常与未配置直说。
|
|
1315
1517
|
* 这段文本才是模型真正读到的东西,所以**任何情况下都不返回空**:读不到数据时也要给出
|
|
1316
1518
|
* 「怎么才能读到」的下一步,否则模型只会回一句「没有额度」,而原因其实是个可以修的动作项。
|
|
1519
|
+
* Command Code 有多个账户时按账户各给一行,标题里标出当前激活的是哪一个
|
|
1520
|
+
* (`accountActive`)或处于自动轮换(`accountMode === "auto"`)——「还剩多少」与
|
|
1521
|
+
* 「现在实际用哪个账户」本来就是同一个问题的两面。
|
|
1317
1522
|
*/
|
|
1318
1523
|
function formatQuotaText(value) {
|
|
1319
1524
|
const providers = Array.isArray(value?.providers) ? value.providers : [];
|
|
1320
|
-
|
|
1321
|
-
const
|
|
1322
|
-
|
|
1525
|
+
const omitted = Number.isFinite(value?.omitted) && value.omitted > 0 ? value.omitted : 0;
|
|
1526
|
+
const tail = omitted ? `\n(另有 ${omitted} 家未启用、未配置凭据或没有可读额度,已省略;需要时用 providers 点名查询某一家的状态。)` : "";
|
|
1527
|
+
if (!providers.length) return omitted
|
|
1528
|
+
? `没有可显示的额度:${omitted} 家都未启用、未配置凭据或没有可读数据。用 providers 点名查询某一家的状态,或在 dsh-subusage 设置页确认「检测」开关与凭据。`
|
|
1529
|
+
: `没有读到任何提供商。请不带 providers 参数再调用一次以查询全部 ${IDS.length} 条;若仍为空,在设置页确认「检测」开关与凭据。`;
|
|
1530
|
+
const lines = providers.map((entry, index) => {
|
|
1531
|
+
const repeat = providers[index - 1]?.providerId === entry.providerId;
|
|
1532
|
+
const head = `${entry.label || entry.providerId} [${entry.state}]${accountSuffix(entry, repeat)}`;
|
|
1323
1533
|
if (entry.state === "no-key" || entry.state === "no-cookie") return `${head}:未配置凭据`;
|
|
1324
1534
|
if (entry.state === "disabled") return `${head}:检测已关闭`;
|
|
1325
1535
|
if (entry.state !== "ok") return `${head}:${entry.error || "读取失败"}`;
|
|
@@ -1327,7 +1537,12 @@ function formatQuotaText(value) {
|
|
|
1327
1537
|
for (const window of entry.windows || []) {
|
|
1328
1538
|
parts.push(`${window.kind} ${window.status === "rate-limited" ? "已达限额" : `${Math.round(Math.max(0, 100 - window.percent) * 10) / 10}% 剩余`}${window.resetsAt ? `(重置 ${window.resetsAt})` : ""}`);
|
|
1329
1539
|
}
|
|
1330
|
-
|
|
1540
|
+
// 账户名已经在标题行里了;再打一遍只会把额度挤出视野。
|
|
1541
|
+
// **只对多账户行跳过**(顶层 account 字段 = Command Code 的账户标注):火山方舟的席位标注
|
|
1542
|
+
// 同样用 kind "account"(`席位 seat-xxx(共 N 个席位)`),那是唯一能看出「读的是哪个席位」的
|
|
1543
|
+
// 信息,跟着一起丢就等于把额度说成不属于任何席位。
|
|
1544
|
+
const skipAccountExtra = typeof entry.account === "string" && entry.account !== "";
|
|
1545
|
+
for (const extra of entry.extras || []) { if (skipAccountExtra && extra.kind === "account") continue; parts.push(`${extra.kind}: ${extra.value}`); }
|
|
1331
1546
|
if (entry.freshness === "stale") parts.push("缓存数据");
|
|
1332
1547
|
return `${head}:${parts.length ? parts.join(";") : "暂无额度数据"}`;
|
|
1333
1548
|
});
|
|
@@ -1337,7 +1552,35 @@ function formatQuotaText(value) {
|
|
|
1337
1552
|
const off = providers.filter(entry => entry.state === "disabled").length;
|
|
1338
1553
|
lines.push(`(这 ${providers.length} 条都没读到额度:未配置凭据 ${missing} 条、检测已关闭 ${off} 条;其余为读取失败。凭据与开关都在 dsh-subusage 设置页,或参考 docs/credentials.md。)`);
|
|
1339
1554
|
}
|
|
1340
|
-
return lines.join("\n");
|
|
1555
|
+
return lines.join("\n") + tail;
|
|
1556
|
+
}
|
|
1557
|
+
/**
|
|
1558
|
+
* 多账户 provider 的账户后缀:账户名 +(当前激活 | 自动轮换 | 轮换候选)。
|
|
1559
|
+
* 同一家的后续行不重复模式词——三行都写一遍「自动轮换」只会白占宽度。
|
|
1560
|
+
*/
|
|
1561
|
+
function accountSuffix(entry, repeat = false) {
|
|
1562
|
+
if (typeof entry?.account !== "string" || entry.account === "") return "";
|
|
1563
|
+
if (repeat && entry.accountMode === "auto") return `(账户 ${entry.account})`;
|
|
1564
|
+
const state = entry.accountMode === "auto" ? "自动轮换" : entry.accountActive === true ? "当前激活" : "轮换候选";
|
|
1565
|
+
return `(账户 ${entry.account} · ${state})`;
|
|
1566
|
+
}
|
|
1567
|
+
/**
|
|
1568
|
+
* 「这条值不值得占一行」(只在调用方**没有点名厂商**时用)。
|
|
1569
|
+
* 省略的是「没启用」「没配凭据」与「成功了但确实没有可读额度」三类:本机登记着三十来条路由,
|
|
1570
|
+
* 这三类只会把真正的数字淹掉。
|
|
1571
|
+
* **读取失败(state=error)刻意保留**:那是一次「没读到」,不是「没配置」——把它一起省略,
|
|
1572
|
+
* 一次网络抖动就会被说成「这家还没配凭据」,把排障方向带偏。
|
|
1573
|
+
* `plan` 是套餐名、`account` 是账户标注,都不算额度数据——「本月没有可读额度」这种占位说明
|
|
1574
|
+
* 正是用户不想看到的空行。但**顶层 account 才是账户标注行**:火山方舟的席位标注也用
|
|
1575
|
+
* kind `account`(`席位 seat-xxx(共 N 个席位)`),那是身份信息而不是账户标注,照旧算数据。
|
|
1576
|
+
*/
|
|
1577
|
+
function hasQuotaData(entry) {
|
|
1578
|
+
if (!entry) return false;
|
|
1579
|
+
if (entry.state === "error") return true;
|
|
1580
|
+
if (entry.state !== "ok") return false;
|
|
1581
|
+
if (Array.isArray(entry.windows) && entry.windows.length) return true;
|
|
1582
|
+
const metadata = typeof entry.account === "string" && entry.account !== "" ? ["account", "plan"] : ["plan"];
|
|
1583
|
+
return (Array.isArray(entry.extras) ? entry.extras : []).some(item => !metadata.includes(item?.kind));
|
|
1341
1584
|
}
|
|
1342
1585
|
/**
|
|
1343
1586
|
* 注册 subusage_quota 工具:模型可以直接问「额度还剩多少」,不必自己逐个厂商请求。
|
|
@@ -1347,7 +1590,7 @@ function registerQuotaTool(ctx) {
|
|
|
1347
1590
|
ctx.inject(["tools"], toolCtx => {
|
|
1348
1591
|
toolCtx.tools.register(defineTool({
|
|
1349
1592
|
name: "subusage_quota",
|
|
1350
|
-
description: "Read the remaining quota and balance of the AI subscriptions configured on this machine (DeepSeek, Z.ai, Kimi, MiMo, OpenCode Go, Command Code, Codex, SuperGrok, MiniMax, Volcengine Ark, OpenRouter, SiliconFlow, ...). Call it with no arguments to read every provider — that is the usual call. `providers` is an optional filter that accepts both this plugin's provider ids and plain vendor names (\"zai\", \"kimi\", \"mimo\", \"minimax\", \"commandcode\", \"grok\", \"codex\", \"ark\", \"deepseek\"); one name can match several routes and every match is returned with its own state, and a name that matches nothing still returns the full data plus an explanation instead of an empty result. Read-only: it never returns credentials and never changes settings. Each entry has state (ok | no-key | no-cookie | error | disabled), windows (percent is the USED percentage; absent resetsAt means the provider does not report one) and extras (plan name, balance). Results may come from a cache up to 60 seconds old; pass refresh=true only when the user explicitly needs up-to-date numbers.",
|
|
1593
|
+
description: "Read the remaining quota and balance of the AI subscriptions configured on this machine (DeepSeek, Z.ai, Kimi, MiMo, OpenCode Go, Command Code, Codex, SuperGrok, MiniMax, Volcengine Ark, OpenRouter, SiliconFlow, ...). Call it with no arguments to read every provider — that is the usual call. `providers` is an optional filter that accepts both this plugin's provider ids and plain vendor names (\"zai\", \"kimi\", \"mimo\", \"minimax\", \"commandcode\", \"grok\", \"codex\", \"ark\", \"deepseek\"); one name can match several routes and every match is returned with its own state, and a name that matches nothing still returns the full data plus an explanation instead of an empty result. Read-only: it never returns credentials and never changes settings. Each entry has state (ok | no-key | no-cookie | error | disabled), windows (percent is the USED percentage; absent resetsAt means the provider does not report one) and extras (plan name, balance). When a known provider is named, its entries are returned as-is so its status is visible (including why it has no data). When nothing is named — or the names you pass are not recognized, in which case the full data set comes back as a fallback — entries whose detection is off, that have no credentials configured, or that succeed without reporting any quota data are left out and counted in `omitted`; entries that failed to fetch, and the explanation row for an unrecognized name, are kept. Command Code is multi-account: it is reported once per account, each entry carrying account (the account label), accountActive and accountMode (fixed = an account is pinned; auto = the provider rotates accounts). Results may come from a cache up to 60 seconds old; pass refresh=true only when the user explicitly needs up-to-date numbers.",
|
|
1351
1594
|
parameters: {
|
|
1352
1595
|
providers: { type: "array", items: { type: "string" }, description: "Optional filter. Either a provider id this tool reports back (\"deepseek\", \"zai-coding-cn\", \"kimi-coding\") or a plain vendor name (\"zai\", \"kimi\", \"mimo\", \"minimax\", \"commandcode\", \"grok\", \"codex\", \"ark\"). Case and separators do not matter; an unmatched name is reported with the full data rather than dropped." },
|
|
1353
1596
|
refresh: { type: "boolean", description: "Force a fresh fetch instead of the up-to-60s cache. Use only when the user asks for the latest numbers." }
|
|
@@ -1402,10 +1645,14 @@ function registerQuotaTool(ctx) {
|
|
|
1402
1645
|
coverage: { type: "string", description: "complete | partial" },
|
|
1403
1646
|
freshness: { type: "string", description: "fresh | stale" },
|
|
1404
1647
|
lastSuccessAt: { type: "string" },
|
|
1648
|
+
account: { type: "string", description: "Account label this entry reports (Command Code only, when the provider has several accounts)." },
|
|
1649
|
+
accountActive: { type: "boolean", description: "true when this account is the one currently pinned as active (Command Code only)." },
|
|
1650
|
+
accountMode: { type: "string", description: "fixed | auto — whether an account is pinned or the provider rotates accounts (Command Code only)." },
|
|
1405
1651
|
error: { type: "string", description: "Failure reason when state is error." }
|
|
1406
1652
|
}
|
|
1407
1653
|
}
|
|
1408
|
-
}
|
|
1654
|
+
},
|
|
1655
|
+
omitted: { type: "number", description: "How many distinct providers were left out of `providers` (counted by provider id) because detection is off, no credentials are configured, or they reported no quota data. Entries that failed to fetch, and the explanation row for an unrecognized name, are never left out. Only set when nothing was named or no name was recognized." }
|
|
1409
1656
|
}
|
|
1410
1657
|
},
|
|
1411
1658
|
render: (_args, value) => [{ type: "text", text: formatQuotaText(value) }]
|
|
@@ -1414,10 +1661,22 @@ function registerQuotaTool(ctx) {
|
|
|
1414
1661
|
exec?.signal?.throwIfAborted?.();
|
|
1415
1662
|
const service = toolCtx.get("subUsage");
|
|
1416
1663
|
if (!service || typeof service.quota !== "function") throw new Error("dsh-subusage 服务尚未就绪:Host 端可能还在启动或刚更新完还没重启。请稍后重试;持续失败就重启 DSH。");
|
|
1417
|
-
|
|
1664
|
+
const asked = Array.isArray(args?.providers) ? args.providers.filter(name => typeof name === "string" && name.trim() !== "") : [];
|
|
1665
|
+
const value = await service.quota({ providerIds: args?.providers, force: args?.refresh === true });
|
|
1666
|
+
if (!Array.isArray(value?.providers)) return value;
|
|
1667
|
+
// 点名的家原样保留(用户问的正是它,包括「为什么没数据」);但**一个名字都没认出来**时,quota()
|
|
1668
|
+
// 会兜底把全量数据一并给出——那次全量仍然要过滤,否则模型拼错一个名字,换来的正是三十条
|
|
1669
|
+
// 未启用条目(本工具最该避免的输出)。认不出的名字自己的说明行照旧保留。
|
|
1670
|
+
const named = new Set(asked.length ? resolveProviderIds(asked).ids : []);
|
|
1671
|
+
const kept = value.providers.filter(entry => named.has(entry.providerId) || !IDS.includes(entry.providerId) || hasQuotaData(entry));
|
|
1672
|
+
// 省略计数按**家**(providerId)去重:多账户的 Command Code 有 N 个未配置账户时报「另有 N 家」是错的。
|
|
1673
|
+
const omitted = new Set(value.providers.filter(entry => !kept.includes(entry)).map(entry => entry.providerId)).size;
|
|
1674
|
+
return omitted ? { ...value, providers: kept, omitted } : { ...value, providers: kept };
|
|
1418
1675
|
}
|
|
1419
1676
|
}));
|
|
1420
1677
|
});
|
|
1421
1678
|
}
|
|
1422
|
-
|
|
1679
|
+
// IDS / PROVIDERS 仅供双端契约测试(test-contract.mjs)核对 Client 的 PROVIDER_META/PROVIDER_ORDER,
|
|
1680
|
+
// 防止新增厂商只改一端:漏改 Host 药丸取不到数、漏改 Client 则设置页与药丸缺项,且都不会报错。
|
|
1681
|
+
export { apply, inject, name, IDS, PROVIDERS };
|
|
1423
1682
|
export default { name, inject, apply };
|