dsh-subusage 0.10.10 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +91 -39
- package/lib/client.js +309 -149
- package/lib/index.js +185 -39
- package/package.json +3 -3
package/lib/index.js
CHANGED
|
@@ -162,6 +162,9 @@ const VOLC_SECRET_KEY_ENV = "VOLC_SECRETKEY";
|
|
|
162
162
|
// 与 @mars-sea/dsh-commandcode-provider 一致的 CLI 版本标识(其常量 COMMAND_CODE_CLI_VERSION)。
|
|
163
163
|
const COMMAND_CODE_CLI_VERSION = "1.73.0";
|
|
164
164
|
const COMMAND_CODE_API_BASE = "https://api.commandcode.ai";
|
|
165
|
+
// 提供方插件(@mars-sea/dsh-commandcode-provider)注册的 settings namespace:账户列表、当前固定账户
|
|
166
|
+
// 与登记任务都在这里。Host 端只读它,不写(切换动作由 Client 走 settings remote)。
|
|
167
|
+
const COMMAND_CODE_SETTINGS_NS = "llm-commandcode";
|
|
165
168
|
const COMMAND_CODE_AUTH_FILE = join(homedir(), ".commandcode", "auth.json");
|
|
166
169
|
// 月总额快照与提供方插件 KNOWN_SUBSCRIPTION_PLANS 对齐(而非月剩余余额)。
|
|
167
170
|
// 来源:https://github.com/Mars-Sea/dsh-commandcode-provider/blob/main/src/capabilities.ts
|
|
@@ -880,6 +883,23 @@ export const subUsageRemote = {
|
|
|
880
883
|
]
|
|
881
884
|
};
|
|
882
885
|
|
|
886
|
+
/**
|
|
887
|
+
* 某条 provider 的缓存键。Command Code 是多账户的:额外账户(凭据引用名)各占一份缓存,
|
|
888
|
+
* 否则账户 A 的结果会被账户 B 顶掉——每读一次全部账户就把前面几家的缓存驱逐一遍,
|
|
889
|
+
* 60 秒 TTL 形同虚设。默认账户(""/"default")与其它 provider 仍按 id 单键。
|
|
890
|
+
*/
|
|
891
|
+
function providerCacheKey(id, account = "") {
|
|
892
|
+
return id === "commandcode" && account !== "" && account !== "default" ? `${id}@${account}` : id;
|
|
893
|
+
}
|
|
894
|
+
/**
|
|
895
|
+
* 把 no-key 文案里的**凭据引用名**(`apiKeyEnv`)换成账户标签。
|
|
896
|
+
* 对外额度视图承诺不返回继承变量名/凭据引用,而错误文案里嵌了引用名(「...无引用 COMMANDCODE_ACCOUNT_WORK」);
|
|
897
|
+
* Client 通道(read/refresh)**保留**引用名——那是本机界面,用户要靠它定位该往哪个 ref 里写凭据。
|
|
898
|
+
*/
|
|
899
|
+
function redactAccountRef(message, ref, label) {
|
|
900
|
+
if (typeof message !== "string" || typeof ref !== "string" || ref === "" || !message.includes(ref)) return message;
|
|
901
|
+
return message.split(ref).join(label);
|
|
902
|
+
}
|
|
883
903
|
// 可注入仅供隔离测试;生产仍由当前 manifest 的 Cordis ctx.plugin 注册。
|
|
884
904
|
export class SubUsageService extends TypertRemoteService {
|
|
885
905
|
constructor(ctx, options = {}) {
|
|
@@ -899,7 +919,12 @@ export class SubUsageService extends TypertRemoteService {
|
|
|
899
919
|
// 默认账户仍走上面的 envName 链(凭据服务 → 启动环境 → ~/.commandcode/auth.json)。
|
|
900
920
|
this.accountCredentials = options.resolveAccountCredentials ?? (async ref => await ctx.get("credentials")?.resolve(credentialRef(ref)));
|
|
901
921
|
this.accountEnvironment = options.resolveAccountEnvironment ?? (ref => launchEnvironmentOf(ctx).get(ref));
|
|
902
|
-
|
|
922
|
+
// Command Code 多账户:账户列表与当前固定账户由提供方插件写在它自己的 settings namespace
|
|
923
|
+
// (llm-commandcode)里,Host 端经 settings 服务读取。服务未挂载(提供方插件未运行)时读不到,
|
|
924
|
+
// 工具退回单账户模式;options.settingsService 供隔离测试注入。
|
|
925
|
+
this.settingsService = options.settingsService;
|
|
926
|
+
try { ctx.inject?.(["settings"], (scope) => { if (scope.settings !== undefined) this.settingsService = scope.settings; }); } catch { /* settings 服务不可用:保持单账户模式 */ }
|
|
927
|
+
this.authFile = options.resolveAuthFile ?? (async () => commandCodeAuthFileKey(this.io));
|
|
903
928
|
// SuperGrok 只读共享登录文件(~/.grok/auth.json 等)取 OAuth Bearer;可注入仅供隔离测试。
|
|
904
929
|
this.grokAuth = options.resolveGrokAuth ?? (async () => grokAuthFileCredentials(this.io));
|
|
905
930
|
// Codex 只读共享登录文件($CODEX_HOME/auth.json 或 ~/.codex/auth.json)取 OAuth access token。
|
|
@@ -1129,6 +1154,49 @@ export class SubUsageService extends TypertRemoteService {
|
|
|
1129
1154
|
if (usageError) data.usageError = usageError;
|
|
1130
1155
|
return data;
|
|
1131
1156
|
}
|
|
1157
|
+
/**
|
|
1158
|
+
* Command Code 的多账户清单与当前激活项,读自提供方插件的 settings namespace(llm-commandcode)。
|
|
1159
|
+
* id/label 规则与 Client 的 commandCodeAccountList 完全一致:默认账户固定 id "default"(label 用人读
|
|
1160
|
+
* 文案),额外账户 id 取其凭据引用名(apiKeyEnv)、label 空白回退 `Account N`;无凭据引用与登记未完成
|
|
1161
|
+
* (phase 非 naming)的账户不列。
|
|
1162
|
+
* 与提供方插件 slots() 的**唯一差异**:它额外支持 `accounts[].apiKey` 字面 Key 账户(id `account-N`),
|
|
1163
|
+
* 本插件**不支持**——那种 Key 只存在提供方自己的配置里,按引用名解析不到(identity() 也没有字面 Key
|
|
1164
|
+
* 分支),列出来只会选中即 no-key。Client 侧同样跳过,两端口径因此保持一致。
|
|
1165
|
+
* activeAccount 为空、为 "auto" 或指向已消失的账户时都按**自动轮换**处理(与其 preferredId 同义)。
|
|
1166
|
+
* 读不到(settings 服务未挂载 / namespace 不存在)返回 null,调用方退回单账户模式。
|
|
1167
|
+
*/
|
|
1168
|
+
commandCodeAccountPlan() {
|
|
1169
|
+
try {
|
|
1170
|
+
// redactSecrets:本插件只读这几个非秘密字段,但提供方配置里可能存在手写的字面 apiKey
|
|
1171
|
+
// (role "secret");带上它就只剥离秘密字段(apiKeyEnv 是 credential-ref,不受影响),
|
|
1172
|
+
// 字面 Key 不会进到本插件的内存里。
|
|
1173
|
+
const rows = this.settingsService?.describe?.({ redactSecrets: true });
|
|
1174
|
+
const row = Array.isArray(rows) ? rows.find(item => item?.ns === COMMAND_CODE_SETTINGS_NS) : undefined;
|
|
1175
|
+
if (!row) return null;
|
|
1176
|
+
const value = row.value && typeof row.value === "object" ? row.value : {};
|
|
1177
|
+
const tasks = Array.isArray(value.accountEnrollmentTasks) ? value.accountEnrollmentTasks : [];
|
|
1178
|
+
const accounts = [{ id: "default", label: "默认账户" }];
|
|
1179
|
+
const seen = new Set(["default"]);
|
|
1180
|
+
for (const [index, account] of (Array.isArray(value.accounts) ? value.accounts : []).entries()) {
|
|
1181
|
+
const ref = typeof account?.apiKeyEnv === "string" && account.apiKeyEnv.trim() !== "" ? account.apiKeyEnv.trim() : void 0;
|
|
1182
|
+
if (ref === void 0) continue;
|
|
1183
|
+
// 同一个引用名出现两次(配置被手改过)时只列一次:否则同账户会出两行、还会多打一份计费请求。
|
|
1184
|
+
if (seen.has(ref)) continue;
|
|
1185
|
+
if (tasks.some(task => task?.ref === ref && task.phase !== "naming")) continue;
|
|
1186
|
+
seen.add(ref);
|
|
1187
|
+
accounts.push({ id: ref, label: typeof account.label === "string" && account.label.trim() !== "" ? account.label.trim() : `Account ${index + 2}` });
|
|
1188
|
+
}
|
|
1189
|
+
const raw = typeof value.activeAccount === "string" ? value.activeAccount.trim() : "";
|
|
1190
|
+
const activeId = raw !== "" && accounts.some(account => account.id === raw) ? raw : "";
|
|
1191
|
+
return { mode: activeId === "" ? "auto" : "fixed", activeId, accounts: accounts.map(account => ({ ...account, active: account.id === activeId })) };
|
|
1192
|
+
} catch { return null; }
|
|
1193
|
+
}
|
|
1194
|
+
/** 按 id 失效一家 provider 的**全部账户**缓存:凭据/开关变化不能只影响默认账户那一条。 */
|
|
1195
|
+
invalidateProvider(id) {
|
|
1196
|
+
const keys = new Set([id]);
|
|
1197
|
+
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);
|
|
1198
|
+
for (const key of keys) { this.generations.set(key, (this.generations.get(key) ?? 0) + 1); this.cache.delete(key); this.inflight.delete(key); }
|
|
1199
|
+
}
|
|
1132
1200
|
base(id, source, settings, account = "") {
|
|
1133
1201
|
// MiMo 会话 Cookie 自签发起 24 小时有效;到期时间随每条 entry 下发,供药丸与设置页倒计时。
|
|
1134
1202
|
const expiresAt = id === "xiaomi-token-plan-cn" ? isoStamp(settings?.xiaomi?.expiresAt) : null;
|
|
@@ -1137,7 +1205,10 @@ export class SubUsageService extends TypertRemoteService {
|
|
|
1137
1205
|
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
1206
|
}
|
|
1139
1207
|
async provider(id, settings, force, account = "") {
|
|
1140
|
-
|
|
1208
|
+
// 缓存/inflight/fingerprint 都按**账户**分键:Command Code 的多账户各占一份,
|
|
1209
|
+
// 读取全部账户时不会互相驱逐缓存(见 providerCacheKey)。
|
|
1210
|
+
const cacheKey = providerCacheKey(id, account);
|
|
1211
|
+
const startedGeneration = this.generations.get(cacheKey) ?? 0;
|
|
1141
1212
|
let identity;
|
|
1142
1213
|
// identity 解析依赖凭据服务/登录文件读取,可能像 HTTP 一样永久挂起:套上与 fetchJson 同量级
|
|
1143
1214
|
// 的超时。超时不等于凭据失效——不清 Cookie、不用 subusage/credentials(client 的
|
|
@@ -1147,25 +1218,25 @@ export class SubUsageService extends TypertRemoteService {
|
|
|
1147
1218
|
try { identity = await withTimeout(this.identity(id, settings, account), this.identityTimeoutMs, () => failure("subusage/identity-timeout", `凭据解析超时(${Math.round(this.identityTimeoutMs / 1000)} 秒无响应):凭据服务或登录文件读取未返回,请稍后重试`)); }
|
|
1148
1219
|
catch (e) {
|
|
1149
1220
|
const timedOut = e?.code === "subusage/identity-timeout";
|
|
1150
|
-
this.cache.delete(
|
|
1221
|
+
this.cache.delete(cacheKey);
|
|
1151
1222
|
return { ...this.base(id, "none", settings, account), state: "error", errorCode: timedOut ? "subusage/identity-timeout" : "subusage/credentials", error: timedOut ? e.message : "无法解析凭据" };
|
|
1152
1223
|
}
|
|
1153
1224
|
if (this.disposed) throw failure("subusage/disposed", "服务已卸载");
|
|
1154
|
-
if (startedGeneration !== (this.generations.get(
|
|
1225
|
+
if (startedGeneration !== (this.generations.get(cacheKey) ?? 0)) return this.provider(id, this.load(), force, account);
|
|
1155
1226
|
if (!providerEnabled(settings, id)) {
|
|
1156
|
-
this.cache.delete(
|
|
1227
|
+
this.cache.delete(cacheKey); this.inflight.delete(cacheKey);
|
|
1157
1228
|
return { ...this.base(id, identity.source, settings, account), state: "disabled", apiDetected: !!identity.key, windows: [], extras: [] };
|
|
1158
1229
|
}
|
|
1159
1230
|
// fingerprint 计入账户选择:切换账户后同 id 缓存/inflight 不串账(不同账户 key 必不同);
|
|
1160
1231
|
// 空串与 "default" 同为默认账户,归一后共享缓存。
|
|
1161
1232
|
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(
|
|
1233
|
+
if (this.fingerprints.has(cacheKey) && this.fingerprints.get(cacheKey) !== fingerprint) this.generations.set(cacheKey, (this.generations.get(cacheKey) ?? 0) + 1);
|
|
1234
|
+
this.fingerprints.set(cacheKey, fingerprint);
|
|
1235
|
+
const generation = this.generations.get(cacheKey) ?? 0;
|
|
1236
|
+
const cached = this.cache.get(cacheKey);
|
|
1237
|
+
if (cached && cached.fingerprint !== fingerprint) this.cache.delete(cacheKey);
|
|
1167
1238
|
const previous = cached?.fingerprint === fingerprint ? cached : undefined;
|
|
1168
|
-
const active = this.inflight.get(
|
|
1239
|
+
const active = this.inflight.get(cacheKey);
|
|
1169
1240
|
if (active?.fingerprint === fingerprint && active.generation === generation) return active.promise;
|
|
1170
1241
|
const now = this.now();
|
|
1171
1242
|
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 +1258,12 @@ export class SubUsageService extends TypertRemoteService {
|
|
|
1187
1258
|
failures++;
|
|
1188
1259
|
if (temporary) retryAt = this.now() + Math.max(e.details?.retryAfterMs ?? 0, Math.min(300000, 1000 * 2 ** Math.min(failures, 8)));
|
|
1189
1260
|
}
|
|
1190
|
-
if (!this.disposed && generation === (this.generations.get(
|
|
1191
|
-
if (this.disposed || generation !== (this.generations.get(
|
|
1261
|
+
if (!this.disposed && generation === (this.generations.get(cacheKey) ?? 0)) this.cache.set(cacheKey, { fingerprint, entry, at: this.now(), retryAt, failures });
|
|
1262
|
+
if (this.disposed || generation !== (this.generations.get(cacheKey) ?? 0)) return { ...this.base(id, "none", settings, account), state: "error", errorCode: "subusage/config-changed", error: "读取期间配置已改变,请刷新" };
|
|
1192
1263
|
return entry;
|
|
1193
1264
|
})();
|
|
1194
|
-
this.inflight.set(
|
|
1195
|
-
try { return await promise; } finally { if (this.inflight.get(
|
|
1265
|
+
this.inflight.set(cacheKey, { fingerprint, generation, promise });
|
|
1266
|
+
try { return await promise; } finally { if (this.inflight.get(cacheKey)?.promise === promise) this.inflight.delete(cacheKey); }
|
|
1196
1267
|
}
|
|
1197
1268
|
// 对外只读额度视图:供其他插件(ctx.get("subUsage").quota(...))与 subusage_quota 工具使用。
|
|
1198
1269
|
// **刻意不返回 settings,也不返回 keySource / apiDetected**:第三方消费者只需要额度,
|
|
@@ -1207,7 +1278,18 @@ export class SubUsageService extends TypertRemoteService {
|
|
|
1207
1278
|
const wanted = matched.length || !unknown.length ? matched : [...IDS];
|
|
1208
1279
|
const settings = this.load();
|
|
1209
1280
|
const force = request?.force === true;
|
|
1210
|
-
|
|
1281
|
+
// Command Code 是多账户的:一次把**每个账户**都读一遍,并标出当前激活的是哪一个(或自动轮换)。
|
|
1282
|
+
// 账户清单来自提供方插件的 settings namespace(llm-commandcode);读不到时退回单账户模式。
|
|
1283
|
+
const plan = wanted.includes("commandcode") ? this.commandCodeAccountPlan() : null;
|
|
1284
|
+
// 激活账户排最前:调用方第一眼要看的就是「现在实际在用的是哪一个」。
|
|
1285
|
+
const planAccounts = plan ? (plan.mode === "fixed" ? [...plan.accounts].sort((a, b) => Number(b.active) - Number(a.active)) : plan.accounts) : null;
|
|
1286
|
+
const read = (id, account = "") => this.provider(id, settings, force, account).catch(() => ({ ...this.base(id, "none", settings, account), state: "error", errorCode: "subusage/internal", error: "读取用量失败" }));
|
|
1287
|
+
const tasks = [];
|
|
1288
|
+
for (const id of wanted) {
|
|
1289
|
+
if (planAccounts && id === "commandcode") tasks.push(...planAccounts.map(account => read(id, account.id)));
|
|
1290
|
+
else tasks.push(read(id));
|
|
1291
|
+
}
|
|
1292
|
+
const entries = await withTimeout(Promise.all(tasks), 30000, () => failure("subusage/timeout", "额度查询超过 30 秒仍未完成,请稍后重试或缩小 providers"));
|
|
1211
1293
|
// 认不出的名字要回一条**可读**条目而不是静默丢掉:调用方(尤其是模型)无从知道本插件用的是
|
|
1212
1294
|
// 路由 id,丢掉之后它只看到空列表,会当成「本机没配任何额度」——真正的原因只是名字没对上。
|
|
1213
1295
|
// 放在最前面,免得淹没在几十条数据后面。
|
|
@@ -1217,19 +1299,28 @@ export class SubUsageService extends TypertRemoteService {
|
|
|
1217
1299
|
}));
|
|
1218
1300
|
return {
|
|
1219
1301
|
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
|
-
|
|
1302
|
+
providers: [...unknownEntries, ...entries.map(entry => {
|
|
1303
|
+
const account = planAccounts && entry.providerId === "commandcode" ? planAccounts.find(item => item.id === (entry.account ?? "default")) : undefined;
|
|
1304
|
+
const extras = Array.isArray(entry.extras) ? entry.extras : [];
|
|
1305
|
+
return {
|
|
1306
|
+
providerId: entry.providerId,
|
|
1307
|
+
label: entry.label,
|
|
1308
|
+
state: entry.state,
|
|
1309
|
+
windows: Array.isArray(entry.windows) ? entry.windows : [],
|
|
1310
|
+
// 账户标注进 extras:单独读 JSON 也能知道这条数字属于哪个账户(文本层把它并进标题行)。
|
|
1311
|
+
extras: account ? [...extras.filter(item => item?.kind !== "account"), { kind: "account", value: account.label }] : extras,
|
|
1312
|
+
// 激活状态是「现在实际用哪一个」的唯一答案:固定账户给 accountActive,自动轮换给 accountMode=auto。
|
|
1313
|
+
...(account ? { account: account.label, accountActive: account.active === true, accountMode: plan.mode } : {}),
|
|
1314
|
+
// 只输出真正有值的字段:视图里带 `undefined` 的键会被调用方的 schema 校验判成型别错误
|
|
1315
|
+
// (DSH 就会),而且「字段缺席」本来就该表示「这家没报告」,不是「报告了空」。
|
|
1316
|
+
...(typeof entry.coverage === "string" ? { coverage: entry.coverage } : {}),
|
|
1317
|
+
...(typeof entry.freshness === "string" ? { freshness: entry.freshness } : {}),
|
|
1318
|
+
...(typeof entry.lastSuccessAt === "string" ? { lastSuccessAt: entry.lastSuccessAt } : {}),
|
|
1319
|
+
// 对外视图里不能出现内部凭据引用名:no-key 文案嵌了它,这里换成账户标签
|
|
1320
|
+
// (client 通道仍保留引用名——那是本机排障要用的定位信息)。
|
|
1321
|
+
...(entry.error ? { error: account ? redactAccountRef(entry.error, account.id, account.label) : entry.error } : {})
|
|
1322
|
+
};
|
|
1323
|
+
})]
|
|
1233
1324
|
};
|
|
1234
1325
|
}
|
|
1235
1326
|
async read(request) { return this.refresh(request && request.providerIds ? request : { providerIds: IDS, force: false }); }
|
|
@@ -1301,10 +1392,10 @@ export class SubUsageService extends TypertRemoteService {
|
|
|
1301
1392
|
updateVolc("secretAccessKey", patch.secretKeyUpdate);
|
|
1302
1393
|
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
1394
|
catch { throw failure("subusage/config", "无法保存订阅设置"); }
|
|
1304
|
-
for (const
|
|
1395
|
+
for (const id of affected) this.invalidateProvider(id);
|
|
1305
1396
|
return this.result(stored, []);
|
|
1306
1397
|
}
|
|
1307
|
-
dispose() { this.login.dispose(); this.disposed = true; this.controller.abort(); this.cache.clear(); this.inflight.clear(); this.fingerprints.clear(); for (const
|
|
1398
|
+
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
1399
|
}
|
|
1309
1400
|
function registerRemotes(ctx) {
|
|
1310
1401
|
ctx.inject(["typert"], scope => { scope.effect(() => scope.typert.register({ package: subUsageRemote.package, face: "host", schemas: [], model: { services: [], events: [], objects: [] }, invocations: subUsageRemote.descriptors })); });
|
|
@@ -1314,12 +1405,20 @@ function apply(ctx) { registerRemotes(ctx); ctx.plugin(SubUsageService, {}); reg
|
|
|
1314
1405
|
* 工具结果的人读文本:一行一个 provider,先给窗口再给余额,异常与未配置直说。
|
|
1315
1406
|
* 这段文本才是模型真正读到的东西,所以**任何情况下都不返回空**:读不到数据时也要给出
|
|
1316
1407
|
* 「怎么才能读到」的下一步,否则模型只会回一句「没有额度」,而原因其实是个可以修的动作项。
|
|
1408
|
+
* Command Code 有多个账户时按账户各给一行,标题里标出当前激活的是哪一个
|
|
1409
|
+
* (`accountActive`)或处于自动轮换(`accountMode === "auto"`)——「还剩多少」与
|
|
1410
|
+
* 「现在实际用哪个账户」本来就是同一个问题的两面。
|
|
1317
1411
|
*/
|
|
1318
1412
|
function formatQuotaText(value) {
|
|
1319
1413
|
const providers = Array.isArray(value?.providers) ? value.providers : [];
|
|
1320
|
-
|
|
1321
|
-
const
|
|
1322
|
-
|
|
1414
|
+
const omitted = Number.isFinite(value?.omitted) && value.omitted > 0 ? value.omitted : 0;
|
|
1415
|
+
const tail = omitted ? `\n(另有 ${omitted} 家未启用、未配置凭据或没有可读额度,已省略;需要时用 providers 点名查询某一家的状态。)` : "";
|
|
1416
|
+
if (!providers.length) return omitted
|
|
1417
|
+
? `没有可显示的额度:${omitted} 家都未启用、未配置凭据或没有可读数据。用 providers 点名查询某一家的状态,或在 dsh-subusage 设置页确认「检测」开关与凭据。`
|
|
1418
|
+
: `没有读到任何提供商。请不带 providers 参数再调用一次以查询全部 ${IDS.length} 条;若仍为空,在设置页确认「检测」开关与凭据。`;
|
|
1419
|
+
const lines = providers.map((entry, index) => {
|
|
1420
|
+
const repeat = providers[index - 1]?.providerId === entry.providerId;
|
|
1421
|
+
const head = `${entry.label || entry.providerId} [${entry.state}]${accountSuffix(entry, repeat)}`;
|
|
1323
1422
|
if (entry.state === "no-key" || entry.state === "no-cookie") return `${head}:未配置凭据`;
|
|
1324
1423
|
if (entry.state === "disabled") return `${head}:检测已关闭`;
|
|
1325
1424
|
if (entry.state !== "ok") return `${head}:${entry.error || "读取失败"}`;
|
|
@@ -1327,7 +1426,12 @@ function formatQuotaText(value) {
|
|
|
1327
1426
|
for (const window of entry.windows || []) {
|
|
1328
1427
|
parts.push(`${window.kind} ${window.status === "rate-limited" ? "已达限额" : `${Math.round(Math.max(0, 100 - window.percent) * 10) / 10}% 剩余`}${window.resetsAt ? `(重置 ${window.resetsAt})` : ""}`);
|
|
1329
1428
|
}
|
|
1330
|
-
|
|
1429
|
+
// 账户名已经在标题行里了;再打一遍只会把额度挤出视野。
|
|
1430
|
+
// **只对多账户行跳过**(顶层 account 字段 = Command Code 的账户标注):火山方舟的席位标注
|
|
1431
|
+
// 同样用 kind "account"(`席位 seat-xxx(共 N 个席位)`),那是唯一能看出「读的是哪个席位」的
|
|
1432
|
+
// 信息,跟着一起丢就等于把额度说成不属于任何席位。
|
|
1433
|
+
const skipAccountExtra = typeof entry.account === "string" && entry.account !== "";
|
|
1434
|
+
for (const extra of entry.extras || []) { if (skipAccountExtra && extra.kind === "account") continue; parts.push(`${extra.kind}: ${extra.value}`); }
|
|
1331
1435
|
if (entry.freshness === "stale") parts.push("缓存数据");
|
|
1332
1436
|
return `${head}:${parts.length ? parts.join(";") : "暂无额度数据"}`;
|
|
1333
1437
|
});
|
|
@@ -1337,7 +1441,35 @@ function formatQuotaText(value) {
|
|
|
1337
1441
|
const off = providers.filter(entry => entry.state === "disabled").length;
|
|
1338
1442
|
lines.push(`(这 ${providers.length} 条都没读到额度:未配置凭据 ${missing} 条、检测已关闭 ${off} 条;其余为读取失败。凭据与开关都在 dsh-subusage 设置页,或参考 docs/credentials.md。)`);
|
|
1339
1443
|
}
|
|
1340
|
-
return lines.join("\n");
|
|
1444
|
+
return lines.join("\n") + tail;
|
|
1445
|
+
}
|
|
1446
|
+
/**
|
|
1447
|
+
* 多账户 provider 的账户后缀:账户名 +(当前激活 | 自动轮换 | 轮换候选)。
|
|
1448
|
+
* 同一家的后续行不重复模式词——三行都写一遍「自动轮换」只会白占宽度。
|
|
1449
|
+
*/
|
|
1450
|
+
function accountSuffix(entry, repeat = false) {
|
|
1451
|
+
if (typeof entry?.account !== "string" || entry.account === "") return "";
|
|
1452
|
+
if (repeat && entry.accountMode === "auto") return `(账户 ${entry.account})`;
|
|
1453
|
+
const state = entry.accountMode === "auto" ? "自动轮换" : entry.accountActive === true ? "当前激活" : "轮换候选";
|
|
1454
|
+
return `(账户 ${entry.account} · ${state})`;
|
|
1455
|
+
}
|
|
1456
|
+
/**
|
|
1457
|
+
* 「这条值不值得占一行」(只在调用方**没有点名厂商**时用)。
|
|
1458
|
+
* 省略的是「没启用」「没配凭据」与「成功了但确实没有可读额度」三类:本机登记着三十来条路由,
|
|
1459
|
+
* 这三类只会把真正的数字淹掉。
|
|
1460
|
+
* **读取失败(state=error)刻意保留**:那是一次「没读到」,不是「没配置」——把它一起省略,
|
|
1461
|
+
* 一次网络抖动就会被说成「这家还没配凭据」,把排障方向带偏。
|
|
1462
|
+
* `plan` 是套餐名、`account` 是账户标注,都不算额度数据——「本月没有可读额度」这种占位说明
|
|
1463
|
+
* 正是用户不想看到的空行。但**顶层 account 才是账户标注行**:火山方舟的席位标注也用
|
|
1464
|
+
* kind `account`(`席位 seat-xxx(共 N 个席位)`),那是身份信息而不是账户标注,照旧算数据。
|
|
1465
|
+
*/
|
|
1466
|
+
function hasQuotaData(entry) {
|
|
1467
|
+
if (!entry) return false;
|
|
1468
|
+
if (entry.state === "error") return true;
|
|
1469
|
+
if (entry.state !== "ok") return false;
|
|
1470
|
+
if (Array.isArray(entry.windows) && entry.windows.length) return true;
|
|
1471
|
+
const metadata = typeof entry.account === "string" && entry.account !== "" ? ["account", "plan"] : ["plan"];
|
|
1472
|
+
return (Array.isArray(entry.extras) ? entry.extras : []).some(item => !metadata.includes(item?.kind));
|
|
1341
1473
|
}
|
|
1342
1474
|
/**
|
|
1343
1475
|
* 注册 subusage_quota 工具:模型可以直接问「额度还剩多少」,不必自己逐个厂商请求。
|
|
@@ -1347,7 +1479,7 @@ function registerQuotaTool(ctx) {
|
|
|
1347
1479
|
ctx.inject(["tools"], toolCtx => {
|
|
1348
1480
|
toolCtx.tools.register(defineTool({
|
|
1349
1481
|
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.",
|
|
1482
|
+
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
1483
|
parameters: {
|
|
1352
1484
|
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
1485
|
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 +1534,14 @@ function registerQuotaTool(ctx) {
|
|
|
1402
1534
|
coverage: { type: "string", description: "complete | partial" },
|
|
1403
1535
|
freshness: { type: "string", description: "fresh | stale" },
|
|
1404
1536
|
lastSuccessAt: { type: "string" },
|
|
1537
|
+
account: { type: "string", description: "Account label this entry reports (Command Code only, when the provider has several accounts)." },
|
|
1538
|
+
accountActive: { type: "boolean", description: "true when this account is the one currently pinned as active (Command Code only)." },
|
|
1539
|
+
accountMode: { type: "string", description: "fixed | auto — whether an account is pinned or the provider rotates accounts (Command Code only)." },
|
|
1405
1540
|
error: { type: "string", description: "Failure reason when state is error." }
|
|
1406
1541
|
}
|
|
1407
1542
|
}
|
|
1408
|
-
}
|
|
1543
|
+
},
|
|
1544
|
+
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
1545
|
}
|
|
1410
1546
|
},
|
|
1411
1547
|
render: (_args, value) => [{ type: "text", text: formatQuotaText(value) }]
|
|
@@ -1414,7 +1550,17 @@ function registerQuotaTool(ctx) {
|
|
|
1414
1550
|
exec?.signal?.throwIfAborted?.();
|
|
1415
1551
|
const service = toolCtx.get("subUsage");
|
|
1416
1552
|
if (!service || typeof service.quota !== "function") throw new Error("dsh-subusage 服务尚未就绪:Host 端可能还在启动或刚更新完还没重启。请稍后重试;持续失败就重启 DSH。");
|
|
1417
|
-
|
|
1553
|
+
const asked = Array.isArray(args?.providers) ? args.providers.filter(name => typeof name === "string" && name.trim() !== "") : [];
|
|
1554
|
+
const value = await service.quota({ providerIds: args?.providers, force: args?.refresh === true });
|
|
1555
|
+
if (!Array.isArray(value?.providers)) return value;
|
|
1556
|
+
// 点名的家原样保留(用户问的正是它,包括「为什么没数据」);但**一个名字都没认出来**时,quota()
|
|
1557
|
+
// 会兜底把全量数据一并给出——那次全量仍然要过滤,否则模型拼错一个名字,换来的正是三十条
|
|
1558
|
+
// 未启用条目(本工具最该避免的输出)。认不出的名字自己的说明行照旧保留。
|
|
1559
|
+
const named = new Set(asked.length ? resolveProviderIds(asked).ids : []);
|
|
1560
|
+
const kept = value.providers.filter(entry => named.has(entry.providerId) || !IDS.includes(entry.providerId) || hasQuotaData(entry));
|
|
1561
|
+
// 省略计数按**家**(providerId)去重:多账户的 Command Code 有 N 个未配置账户时报「另有 N 家」是错的。
|
|
1562
|
+
const omitted = new Set(value.providers.filter(entry => !kept.includes(entry)).map(entry => entry.providerId)).size;
|
|
1563
|
+
return omitted ? { ...value, providers: kept, omitted } : { ...value, providers: kept };
|
|
1418
1564
|
}
|
|
1419
1565
|
}));
|
|
1420
1566
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-subusage",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"displayName": "订阅用量显示",
|
|
5
5
|
"description": "显示 DeepSeek 官方余额、火山方舟(arkcli 官方 CLI 路由:Agent / Coding Plan 个人版与企业版席位,设置页合并为一组)、Z.ai(中国与国际)、Kimi、MiMo、OpenCode Go、Command Code、SuperGrok、Codex(ChatGPT 订阅,默认关闭)、MiniMax(中国)、Synthetic、NanoGPT、SiliconFlow、OpenRouter、Novita、Hyperbolic、DeepInfra、Chutes、Ollama Cloud、Vercel AI Gateway、ZenMux、LiteLLM 的订阅余量与余额;共享刷新与凭据管理,默认关闭 17 家。",
|
|
6
6
|
"keywords": [
|
|
@@ -61,9 +61,9 @@
|
|
|
61
61
|
"node": ">=22.19.0",
|
|
62
62
|
"dshReleases": {
|
|
63
63
|
"0.2.0-rc.1": "unknown",
|
|
64
|
-
"0.2.0-rc.2": "
|
|
64
|
+
"0.2.0-rc.2": "compatible",
|
|
65
65
|
"0.2.1-alpha.1": "compatible",
|
|
66
|
-
"0.2.1-alpha.2": "
|
|
66
|
+
"0.2.1-alpha.2": "compatible"
|
|
67
67
|
}
|
|
68
68
|
},
|
|
69
69
|
"client": {
|