@kenz1117/dsh-ui-usage-billing 1.6.2 → 1.6.4

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/lib/index.js CHANGED
@@ -1311,6 +1311,18 @@ const BUILTIN_MODEL_CATALOG = [
1311
1311
  output: 16.8
1312
1312
  }
1313
1313
  },
1314
+ {
1315
+ key: "minimax-m3.1-flash",
1316
+ name: "MiniMax-M3.1-Flash",
1317
+ provider: "MiniMax",
1318
+ colorVar: "ds-amber",
1319
+ price: {
1320
+ currency: "CNY",
1321
+ input: 1,
1322
+ cacheHit: .1,
1323
+ output: 4
1324
+ }
1325
+ },
1314
1326
  {
1315
1327
  key: "minimax-m3.1-flash-preview",
1316
1328
  name: "MiniMax M3.1 Flash Preview",
@@ -2423,6 +2435,8 @@ const BUILTIN_MODEL_KEY_ALIASES = {
2423
2435
  "minimax-m2-7": "minimax-m2.7",
2424
2436
  "minimax-m2-7-highspeed": "minimax-m2.7-highspeed",
2425
2437
  "minimax-m2-7-high-speed": "minimax-m2.7-highspeed",
2438
+ "minimax-m3.1-flash": "minimax-m3.1-flash",
2439
+ "minimax-m3-1-flash": "minimax-m3.1-flash",
2426
2440
  "minimax-m3.1-flash-preview": "minimax-m3.1-flash-preview",
2427
2441
  "minimax-m3-1-flash-preview": "minimax-m3.1-flash-preview",
2428
2442
  "u2-flash": "u2-flash",
@@ -4045,6 +4059,35 @@ function createUsageAggregator(persistence, options = {}) {
4045
4059
  };
4046
4060
  }
4047
4061
  //#endregion
4062
+ //#region lib/types/route-keys.js
4063
+ /**
4064
+ * 宿主 llm-pi-ai 新代际的路由 key 约定(订阅额度 / 余额 / 中转站 / 声明端点共用)。
4065
+ *
4066
+ * 新版宿主设置页把路由 key 直接存成凭据 refs(命名 = 路由 id 大写下划线 +
4067
+ * `_API_KEY`),providers 配置里只剩 models 列表;chat 认证走 pi-ai 目录路由的
4068
+ * ambient 发现,不再需要显式 `apiKeyEnv`。插件侧所有「按路由取 key」的读取口
4069
+ * 共用本模块的候选名推导与顺序解析——显式引用优先,约定名补后。
4070
+ */
4071
+ /** 候选 ref 名:路由 id 约定名(`<ROUTE 大写下划线>_API_KEY`)优先,其后补宿主
4072
+ * pi-ai 目录的 ambient env 名(仅当与约定名不同——目录把 kimi-coding 指到
4073
+ * KIMI_API_KEY、opencode-go 与 opencode 同用 OPENCODE_API_KEY)。 */
4074
+ function routeKeyRefCandidates(route) {
4075
+ return [`${route.replaceAll("-", "_").toUpperCase()}_API_KEY`, ...{
4076
+ "kimi-coding": ["KIMI_API_KEY"],
4077
+ "opencode-go": ["OPENCODE_API_KEY"]
4078
+ }[route] ?? []];
4079
+ }
4080
+ /** 依次解析候选 ref,返回第一个解析出非空值的 ref 与值;全部落空返回 undefined。 */
4081
+ async function firstResolvedRef(refs, resolveRef) {
4082
+ for (const ref of refs) {
4083
+ const value = await resolveRef(ref);
4084
+ if (value !== void 0) return {
4085
+ ref,
4086
+ value
4087
+ };
4088
+ }
4089
+ }
4090
+ //#endregion
4048
4091
  //#region lib/types/resilience.js
4049
4092
  /**
4050
4093
  * 上游网络请求的稳定性工具:有限重试(指数退避)与每平台熔断冷却门。
@@ -4650,32 +4693,51 @@ const QUERIERS = [
4650
4693
  ];
4651
4694
  /**
4652
4695
  * Query every configured provider's account balance. A provider is queried only
4653
- * when its llm-pi-ai route has an `apiKeyEnv`; absent routes answer
4654
- * `unconfigured` so the dashboard shows a stable state instead of dropping the row.
4696
+ * when its llm-pi-ai route has a key: an explicit `apiKeyEnv`, or — on hosts
4697
+ * whose settings UI stores route keys as credentials refs — the route's
4698
+ * conventional ref (route-keys.ts). Absent routes answer `unconfigured` so the
4699
+ * dashboard shows a stable state instead of dropping the row.
4655
4700
  * @param ctx - host context carrying the credentials seam.
4656
4701
  * @param providers - the llm-pi-ai providers dict (`<route> → { apiKeyEnv? }`).
4657
4702
  * @returns the balance rows (one per provider).
4658
4703
  */
4659
4704
  async function queryBalances(ctx, providers) {
4660
- const byName = /* @__PURE__ */ new Map();
4661
- for (const { route, querier, displayName } of QUERIERS) {
4705
+ const resolveRef = async (name) => {
4706
+ try {
4707
+ const hit = await ctx.credentials.resolve(credentialRef(name));
4708
+ return hit?.value !== void 0 && hit.value !== "" ? hit.value : void 0;
4709
+ } catch {
4710
+ return;
4711
+ }
4712
+ };
4713
+ const resolved = await Promise.all(QUERIERS.map(async ({ route, querier, displayName }) => {
4662
4714
  const env = providers[route]?.apiKeyEnv;
4663
- const configured = typeof env === "string" && env !== "";
4664
- const existing = byName.get(displayName);
4665
- if (existing !== void 0 && existing.env !== void 0) continue;
4666
- byName.set(displayName, {
4715
+ const explicit = typeof env === "string" && env !== "" ? env : void 0;
4716
+ const hit = await firstResolvedRef([...explicit !== void 0 ? [explicit] : [], ...routeKeyRefCandidates(route)], resolveRef);
4717
+ return {
4667
4718
  displayName,
4668
4719
  querier,
4669
- env: configured ? env : void 0
4720
+ ref: hit?.ref ?? explicit,
4721
+ configured: explicit !== void 0 || hit !== void 0
4722
+ };
4723
+ }));
4724
+ const byName = /* @__PURE__ */ new Map();
4725
+ for (const item of resolved) {
4726
+ const existing = byName.get(item.displayName);
4727
+ if (existing !== void 0 && existing.ref !== void 0) continue;
4728
+ byName.set(item.displayName, {
4729
+ displayName: item.displayName,
4730
+ querier: item.querier,
4731
+ ref: item.configured ? item.ref : void 0
4670
4732
  });
4671
4733
  }
4672
- return await Promise.all([...byName.values()].map(({ querier, displayName, env }) => {
4673
- if (env === void 0) return Promise.resolve({
4734
+ return await Promise.all([...byName.values()].map(({ displayName, querier, ref }) => {
4735
+ if (ref === void 0) return Promise.resolve({
4674
4736
  provider: displayName,
4675
4737
  displayName,
4676
4738
  error: "unconfigured"
4677
4739
  });
4678
- return querier(ctx, env);
4740
+ return querier(ctx, ref);
4679
4741
  }));
4680
4742
  }
4681
4743
  /**
@@ -4913,8 +4975,9 @@ async function queryCustomBalances(ctx, configs) {
4913
4975
  * 2. `path` 必须单斜杠绝对路径:`//evil.example/x` 是协议相对 URL,`new URL()` 会
4914
4976
  * 把它解析到别的主机;构造后还会再校验一次 origin。
4915
4977
  * 3. 只发 GET,无请求体,无自定义 method / headers。
4916
- * 4. 凭据仍从匹配 provider 自己的 `apiKeyEnv` 取,经同一凭据 seam 解析;声明不能
4917
- * 指定任何凭据。
4978
+ * 4. 凭据仍只从匹配 provider 自己的凭据取:显式 `apiKeyEnv` 优先,缺省按路由
4979
+ * 约定 ref(新版宿主的存放形态,见 route-keys.ts)经同一凭据 seam 解析;声明
4980
+ * 不能指定任何凭据。
4918
4981
  * 5. 跨源重定向直接失败,不跟随——那是绕过第 1 条最省事的办法。
4919
4982
  * 6. 响应体有大小上限与共享超时,坏 / 恶意端点拖不住面板。
4920
4983
  * 7. 声明不能覆盖内置读法:只在内置表答不上来时它才轮到。
@@ -5052,10 +5115,21 @@ function compileDeclaredEndpoint(decl) {
5052
5115
  */
5053
5116
  async function queryDeclaredEndpoints(ctx, providers, declarations) {
5054
5117
  const byOrigin = /* @__PURE__ */ new Map();
5055
- for (const entry of Object.values(providers)) {
5118
+ for (const [route, entry] of Object.entries(providers)) {
5056
5119
  const origin = entry.baseURL === void 0 ? void 0 : normalizeDeclaredOrigin(entry.baseURL);
5057
- if (origin !== void 0 && !byOrigin.has(origin)) byOrigin.set(origin, entry);
5120
+ if (origin !== void 0 && !byOrigin.has(origin)) byOrigin.set(origin, {
5121
+ route,
5122
+ ...entry.apiKeyEnv !== void 0 ? { apiKeyEnv: entry.apiKeyEnv } : {}
5123
+ });
5058
5124
  }
5125
+ const resolveRef = async (name) => {
5126
+ try {
5127
+ const hit = await ctx.credentials.resolve(credentialRef(name));
5128
+ return hit?.value !== void 0 && hit.value !== "" ? hit.value : void 0;
5129
+ } catch {
5130
+ return;
5131
+ }
5132
+ };
5059
5133
  return await Promise.all(declarations.map(async (decl) => {
5060
5134
  const displayName = typeof decl.displayName === "string" && decl.displayName !== "" ? decl.displayName : "已声明";
5061
5135
  const provider = `declared:${displayName}`;
@@ -5083,8 +5157,8 @@ async function queryDeclaredEndpoints(ctx, providers, declarations) {
5083
5157
  declared: true,
5084
5158
  error: "unreachable"
5085
5159
  };
5086
- const hit = await ctx.credentials.resolve(credentialRef(matched.apiKeyEnv ?? ""));
5087
- if (hit === void 0 || hit.value === "") return {
5160
+ const hit = await firstResolvedRef([...matched.apiKeyEnv !== void 0 ? [matched.apiKeyEnv] : [], ...routeKeyRefCandidates(matched.route)], resolveRef);
5161
+ if (hit === void 0) return {
5088
5162
  provider,
5089
5163
  displayName,
5090
5164
  declared: true,
@@ -5712,7 +5786,11 @@ function swallowed(reason, error) {
5712
5786
  * other subscription providers the harness recognizes (volcengine / baidu /
5713
5787
  * qwen / xiaomi token plans, agent plans…) are identified and listed with a
5714
5788
  * "no quota API" marker rather than hidden. API keys come from the `llm-pi-ai`
5715
- * settings namespace (`apiKeyEnv` refs) resolved through the credentials seam.
5789
+ * settings namespace resolved through the credentials seam: the classic
5790
+ * `apiKeyEnv` refs, or — on hosts whose settings UI stores route keys as
5791
+ * credentials refs named after the route (`<ROUTE>_API_KEY`, catalog ambient
5792
+ * names as fallback) — the conventional refs, matched against the same
5793
+ * subscription-class route ids.
5716
5794
  */
5717
5795
  /** 每平台熔断门:某个订阅适配器连续可重试失败(网络 / 5xx / 429)达阈值后
5718
5796
  * 短路由不可用,避免 30 秒轮询对已故障的上游反复打满超时。鉴权失败(unauthorized)
@@ -5761,6 +5839,88 @@ const EMPTY_SUBSCRIPTION_KEYS = {
5761
5839
  tencentCloudApi: "",
5762
5840
  zaiRegion: "global"
5763
5841
  };
5842
+ /** 订阅 provider id(llm-pi-ai 设置键)→ SubscriptionKeys 字段的映射。 */
5843
+ const SUBSCRIPTION_KEY_SOURCES = [
5844
+ {
5845
+ provider: "kimi-coding",
5846
+ key: "kimiApiKey"
5847
+ },
5848
+ {
5849
+ provider: "zai-coding-cn",
5850
+ key: "zaiCnApiKey"
5851
+ },
5852
+ {
5853
+ provider: "zai-coding",
5854
+ key: "zaiApiKey"
5855
+ },
5856
+ {
5857
+ provider: "opencode",
5858
+ key: "opencodeApiKey"
5859
+ },
5860
+ {
5861
+ provider: "opencode-go",
5862
+ key: "opencodeApiKey"
5863
+ },
5864
+ {
5865
+ provider: "minimax",
5866
+ key: "minmaxApiKey"
5867
+ },
5868
+ {
5869
+ provider: "minimax-token-plan",
5870
+ key: "minmaxApiKey"
5871
+ },
5872
+ {
5873
+ provider: "minimax-token-plan-cn",
5874
+ key: "minmaxApiKey"
5875
+ },
5876
+ {
5877
+ provider: "minimax-cn",
5878
+ key: "minmaxApiKey"
5879
+ },
5880
+ {
5881
+ provider: "openrouter",
5882
+ key: "openrouterApiKey"
5883
+ },
5884
+ {
5885
+ provider: "commandcode",
5886
+ key: "commandcodeApiKey"
5887
+ },
5888
+ {
5889
+ provider: "tencent-token-plan",
5890
+ key: "tencentCloudApi"
5891
+ }
5892
+ ];
5893
+ /**
5894
+ * key 解析的可测核心(无文件 I/O):先显式 `apiKeyEnv`(旧口径,优先),key 仍缺
5895
+ * 时按约定 ref 名解析(新版宿主的存放形态,见 route-keys.ts)。`keyConfigured`
5896
+ * 回报每条路由的 key 是否解析成功,供识别层作为第三配置信号(refResolved)。
5897
+ * @param providers - llm-pi-ai 的 providers 视图。
5898
+ * @param resolveRef - 凭据 seam:ref 名 → 非空 key 值;未配置/失败返回 undefined。
5899
+ */
5900
+ async function resolveSubscriptionAccess(providers, resolveRef) {
5901
+ const keys = { ...EMPTY_SUBSCRIPTION_KEYS };
5902
+ const keyConfigured = /* @__PURE__ */ new Set();
5903
+ for (const { provider, key } of SUBSCRIPTION_KEY_SOURCES) {
5904
+ const env = providers[provider]?.apiKeyEnv;
5905
+ if (typeof env === "string" && env !== "") {
5906
+ const value = await resolveRef(env);
5907
+ if (value !== void 0) {
5908
+ keys[key] = value;
5909
+ keyConfigured.add(provider);
5910
+ continue;
5911
+ }
5912
+ }
5913
+ const hit = await firstResolvedRef(routeKeyRefCandidates(provider), resolveRef);
5914
+ if (hit !== void 0) {
5915
+ keys[key] = hit.value;
5916
+ keyConfigured.add(provider);
5917
+ }
5918
+ }
5919
+ return {
5920
+ keys,
5921
+ keyConfigured
5922
+ };
5923
+ }
5764
5924
  /** 订阅类 provider 的显示名(未命中的回退为 id 本身)。 */
5765
5925
  const SUBSCRIPTION_DISPLAY_NAMES = {
5766
5926
  "kimi-coding": "Kimi For Coding",
@@ -5814,14 +5974,15 @@ const SUBSCRIPTION_ADAPTERS = {
5814
5974
  /** 有额度适配器的 provider id 集合(识别用)。 */
5815
5975
  const ADAPTER_PROVIDER_IDS = new Set(Object.keys(SUBSCRIPTION_ADAPTERS));
5816
5976
  /**
5817
- * 从 llm-pi-ai 设置里识别订阅套餐:带订阅类 id 且配置了 apiKeyEnv 的 provider。
5977
+ * 从 llm-pi-ai 设置里识别订阅套餐:带订阅类 id 且任一配置信号成立的 provider
5978
+ * (显式 apiKeyEnv / models 已配置 / 约定 ref 解析出了 key)。
5818
5979
  * @param providers - the `providers` map of the llm-pi-ai settings namespace.
5819
5980
  * @returns identified plans in configuration order.
5820
5981
  */
5821
5982
  function identifySubscriptionPlans(providers) {
5822
5983
  const out = [];
5823
5984
  for (const [id, config] of Object.entries(providers ?? {})) {
5824
- if (typeof config?.apiKeyEnv !== "string" || config.apiKeyEnv === "") continue;
5985
+ if (!(typeof config?.apiKeyEnv === "string" && config.apiKeyEnv !== "" || config?.hasModels === true || config?.refResolved === true)) continue;
5825
5986
  if (!isSubscriptionProviderId(id)) continue;
5826
5987
  out.push({
5827
5988
  provider: id,
@@ -6552,9 +6713,10 @@ async function collectSubscriptions(keys, plans = [], timeoutMs = DEFAULT_TIMEOU
6552
6713
  *
6553
6714
  * 适用场景:用户把某条 llm-pi-ai provider 路由的 `baseURL` 指向第三方中转站
6554
6715
  * (New API / One API / VoAPI / Sub2API 等)。这类站点不卖官方余额,卖的是
6555
- * 按 key 的额度(used/total)或多个滚动窗口。本模块对**配了 baseURL 且有
6556
- * apiKeyEnv** 的路由逐个探测两个已知端点,能解析出额度就返回;解析不出的
6557
- * 静默标记 unavailable,绝不臆造金额(与 balance/subscriptions 一致的姿态)。
6716
+ * 按 key 的额度(used/total)或多个滚动窗口。本模块对**配了 baseURL 且 key 可得**
6717
+ * (显式 `apiKeyEnv`,或新版宿主存进凭据 refs 的路由约定名,见 route-keys.ts)
6718
+ * 的路由逐个探测两个已知端点,能解析出额度就返回;解析不出的静默标记
6719
+ * unavailable,绝不臆造金额(与 balance/subscriptions 一致的姿态)。
6558
6720
  *
6559
6721
  * 探测顺序:先 Sub2API `/v1/usage`(标准化程度高),再 New API `/api/status`;
6560
6722
  * 404 = 不是该套程序,继续试下一种;401/403 = 是但 key 不对(unauthorized);
@@ -6744,7 +6906,7 @@ async function readRelayByKind(baseURL, apiKey, kind) {
6744
6906
  /**
6745
6907
  * 查询单个中转站路由的额度。先试 Sub2API,再试 New API;任一读出额度即返回。
6746
6908
  * @param ctx - host context carrying the credentials seam.
6747
- * @param route - 待探测的路由(baseURL + apiKeyEnv)。
6909
+ * @param route - 待探测的路由(baseURL + 凭据 ref 候选)。
6748
6910
  * @returns 该路由的一行额度结果(status 标记成败)。
6749
6911
  */
6750
6912
  async function queryRelayQuota(ctx, route) {
@@ -6753,32 +6915,32 @@ async function queryRelayQuota(ctx, route) {
6753
6915
  origin: originOf(route.baseURL),
6754
6916
  displayName: route.displayName ?? route.route
6755
6917
  };
6756
- const gateKey = `${route.baseURL}::${route.apiKeyEnv}`;
6918
+ const gateKey = `${route.baseURL}::${route.route}`;
6757
6919
  if (!relayGate.check(gateKey)) return {
6758
6920
  ...base,
6759
6921
  kind: "unknown",
6760
6922
  status: "unavailable"
6761
6923
  };
6762
- let hit;
6763
- try {
6764
- hit = await ctx.credentials.resolve(credentialRef(route.apiKeyEnv));
6765
- } catch {
6766
- return {
6767
- ...base,
6768
- kind: "unknown",
6769
- status: "not-configured"
6770
- };
6771
- }
6772
- if (hit === void 0 || hit.value === "") return {
6924
+ const resolveRef = async (name) => {
6925
+ try {
6926
+ const hit = await ctx.credentials.resolve(credentialRef(name));
6927
+ return hit?.value !== void 0 && hit.value !== "" ? hit.value : void 0;
6928
+ } catch {
6929
+ return;
6930
+ }
6931
+ };
6932
+ const hit = await firstResolvedRef(route.apiKeyRefs, resolveRef);
6933
+ if (hit === void 0) return {
6773
6934
  ...base,
6774
6935
  kind: "unknown",
6775
6936
  status: "not-configured"
6776
6937
  };
6938
+ const apiKey = hit.value;
6777
6939
  const cached = fingerprintCache.get(base.origin);
6778
6940
  const knownKind = cached !== void 0 && Date.now() - cached.at < FINGERPRINT_TTL_MS ? cached.kind : void 0;
6779
6941
  try {
6780
6942
  if (knownKind === "sub2api" || knownKind === "new-api") {
6781
- const parsed = await readRelayByKind(route.baseURL, hit.value, knownKind);
6943
+ const parsed = await readRelayByKind(route.baseURL, apiKey, knownKind);
6782
6944
  if (parsed !== null) {
6783
6945
  relayGate.success(gateKey);
6784
6946
  return {
@@ -6796,7 +6958,7 @@ async function queryRelayQuota(ctx, route) {
6796
6958
  status: "invalid-response"
6797
6959
  };
6798
6960
  }
6799
- const sub2 = await fetchRelayJson(endpointOf(route.baseURL, "/v1/usage"), hit.value);
6961
+ const sub2 = await fetchRelayJson(endpointOf(route.baseURL, "/v1/usage"), apiKey);
6800
6962
  if (sub2.ok) {
6801
6963
  const parsed = parseSub2ApiUsage(sub2.data);
6802
6964
  if (parsed !== null) {
@@ -6820,7 +6982,7 @@ async function queryRelayQuota(ctx, route) {
6820
6982
  status: "invalid-response"
6821
6983
  };
6822
6984
  }
6823
- const na = await fetchRelayJson(endpointOf(route.baseURL, "/api/status"), hit.value);
6985
+ const na = await fetchRelayJson(endpointOf(route.baseURL, "/api/status"), apiKey);
6824
6986
  if (na.ok) {
6825
6987
  const parsed = parseNewApiStatus(na.data);
6826
6988
  if (parsed !== null) {
@@ -6862,7 +7024,7 @@ async function queryRelayQuota(ctx, route) {
6862
7024
  /**
6863
7025
  * 批量查询多个中转站路由的额度(每个独立成败,互不影响)。
6864
7026
  * @param ctx - host context carrying the credentials seam.
6865
- * @param routes - 配了 baseURL 且 apiKeyEnv 有值的路由列表。
7027
+ * @param routes - 配了 baseURL 且 key 候选可得(显式引用存在或约定 ref 解析出值)的路由列表。
6866
7028
  * @returns 每个路由一行的额度结果。
6867
7029
  */
6868
7030
  async function queryRelayQuotas(ctx, routes) {
@@ -7142,6 +7304,8 @@ function registerBalanceRoute(ctx, deps) {
7142
7304
  *
7143
7305
  * usage_stats 工具开关:插件自带的读/写接口(不依赖宿主浏览器设置白名单)。
7144
7306
  * GET 返回当前是否注入;POST 写设置命名空间(重启应用后工具注入随新值生效)。
7307
+ * 写回失败回 503 + 宿主错误文案(issue #98:此前 200 + 笼统 invalid,开关无声
7308
+ * 弹回且无任何诊断线索);body 非 JSON 回 400。
7145
7309
  *
7146
7310
  * 双世代宿主(issue #19 + #28 + v1.4.19)的接线在装配器里完成:
7147
7311
  * - 旧宿主 ≤0.1.6 走 `settings.register` 命名空间 scope。
@@ -7174,32 +7338,43 @@ function registerUsageToolRoute(ctx, deps) {
7174
7338
  return;
7175
7339
  }
7176
7340
  }
7177
- res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
7178
7341
  if (req.method === "GET") {
7342
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
7179
7343
  res.end(JSON.stringify({ enabled: deps.read() }));
7180
7344
  return;
7181
7345
  }
7182
- try {
7183
- let body = "";
7184
- for await (const chunk of req) {
7185
- body += String(chunk);
7186
- if (body.length > 512) {
7187
- res.writeHead(413, { "content-type": "application/json; charset=utf-8" });
7188
- res.end(JSON.stringify({ error: "body too large" }));
7189
- return;
7190
- }
7346
+ let body = "";
7347
+ for await (const chunk of req) {
7348
+ body += String(chunk);
7349
+ if (body.length > 512) {
7350
+ res.writeHead(413, { "content-type": "application/json; charset=utf-8" });
7351
+ res.end(JSON.stringify({ error: "body too large" }));
7352
+ return;
7191
7353
  }
7192
- const next = JSON.parse(body === "" ? "{}" : body).enabled === true;
7354
+ }
7355
+ let next;
7356
+ try {
7357
+ next = JSON.parse(body === "" ? "{}" : body).enabled === true;
7358
+ } catch {
7359
+ res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
7360
+ res.end(JSON.stringify({ error: "invalid json body" }));
7361
+ return;
7362
+ }
7363
+ try {
7193
7364
  await deps.write(next);
7194
- deps.syncTool();
7195
- res.end(JSON.stringify({
7196
- ok: true,
7197
- enabled: next
7198
- }));
7199
7365
  } catch (error) {
7366
+ const detail = error instanceof Error ? error.message : String(error);
7200
7367
  console.warn("[usage-billing] usage-tool toggle write-back failed:", error);
7201
- res.end(JSON.stringify({ error: "invalid" }));
7368
+ res.writeHead(503, { "content-type": "application/json; charset=utf-8" });
7369
+ res.end(JSON.stringify({ error: `write failed: ${detail}` }));
7370
+ return;
7202
7371
  }
7372
+ deps.syncTool();
7373
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
7374
+ res.end(JSON.stringify({
7375
+ ok: true,
7376
+ enabled: next
7377
+ }));
7203
7378
  }
7204
7379
  }), "usage-billing: usage-tool route");
7205
7380
  }
@@ -7435,6 +7610,31 @@ const BILLING_SETTINGS_NAMESPACE = "ui-usage-billing";
7435
7610
  /** 该命名空间下用户可编辑的字段名。 */
7436
7611
  const ENABLE_USAGE_STATS_TOOL_FIELD = "enableUsageStatsTool";
7437
7612
  //#endregion
7613
+ //#region lib/types/config.js
7614
+ /**
7615
+ * Plugin configuration(v1.5.2 抽出):UsageBillingConfig 原本声明在 src/index.ts
7616
+ * 顶部,src/index.ts 收缩为装配器后被 src/routes/usage-stats.ts 等路由文件
7617
+ * 引用(作为 Deps 类型签名),把它单独放这里让 route 文件能从 src/ 根 import。
7618
+ * 仍由 src/index.ts 的 apply(ctx, config) 消费:路由层只通过 Deps 拿到它,
7619
+ * 不直接构造。
7620
+ *
7621
+ * ⚠️ Config 运行时 schema 必须经 src/index.ts re-export(issue #98):宿主
7622
+ * dsh-settings@0.2.1-rc.2 的 settings.update 写回要求本插件条目在 loader fiber
7623
+ * 上有 `Config` schema(`entry.fiber.runtime.Config` 且带 toJSON),缺失时抛
7624
+ * `No configurable plugin entry`——v1.6.0 拆分时把 schema 退化成纯类型,「注入
7625
+ * 用量查询」开关随之失效(#80 方案的回归)。schemastery 非 strict object 会
7626
+ * merge 未声明键,其余 config 字段(statsPath / routeAliases 等)不受影响。
7627
+ */
7628
+ /**
7629
+ * 宿主侧条目 config schema(#80 / #98):宿主 0.1.7+ 的设置系统据此把
7630
+ * `enableUsageStatsTool` 投影为 volatile 引用(设置写入即时反映 + 写回走
7631
+ * settings.update)。**必须**经 src/index.ts re-export 给 loader——宿主从插件
7632
+ * 运行时模块的 `Config` 导出发现它;只声明类型等于没有。schemastery 非 strict
7633
+ * object 透传未声明键,其余 config 字段不受影响;未导出 Config 的旧宿主直接
7634
+ * 把原始 config 交给 apply,普通布尔形态由读取端兼容。
7635
+ */
7636
+ const Config = z.object({ [ENABLE_USAGE_STATS_TOOL_FIELD]: z.boolean().default(false).volatile() });
7637
+ //#endregion
7438
7638
  //#region lib/types/index.js
7439
7639
  /**
7440
7640
  * Usage billing surface plugin, node half.
@@ -7584,61 +7784,6 @@ const inject = [
7584
7784
  "credentials",
7585
7785
  "settings"
7586
7786
  ];
7587
- /**
7588
- * 订阅 provider id(llm-pi-ai 设置键)→ billing 适配器 key 的映射。
7589
- * 复用 dsh 既有的 llm-pi-ai provider 配置(apiKeyEnv 引用),不引入新配置面。
7590
- */
7591
- /** key 只取字符串凭据字段:zaiRegion 是区域枚举,由下方区域逻辑单独赋值。 */
7592
- const SUBSCRIPTION_KEY_SOURCES = [
7593
- {
7594
- provider: "kimi-coding",
7595
- key: "kimiApiKey"
7596
- },
7597
- {
7598
- provider: "zai-coding-cn",
7599
- key: "zaiCnApiKey"
7600
- },
7601
- {
7602
- provider: "zai-coding",
7603
- key: "zaiApiKey"
7604
- },
7605
- {
7606
- provider: "opencode",
7607
- key: "opencodeApiKey"
7608
- },
7609
- {
7610
- provider: "opencode-go",
7611
- key: "opencodeApiKey"
7612
- },
7613
- {
7614
- provider: "minimax",
7615
- key: "minmaxApiKey"
7616
- },
7617
- {
7618
- provider: "minimax-token-plan",
7619
- key: "minmaxApiKey"
7620
- },
7621
- {
7622
- provider: "minimax-token-plan-cn",
7623
- key: "minmaxApiKey"
7624
- },
7625
- {
7626
- provider: "minimax-cn",
7627
- key: "minmaxApiKey"
7628
- },
7629
- {
7630
- provider: "openrouter",
7631
- key: "openrouterApiKey"
7632
- },
7633
- {
7634
- provider: "commandcode",
7635
- key: "commandcodeApiKey"
7636
- },
7637
- {
7638
- provider: "tencent-token-plan",
7639
- key: "tencentCloudApi"
7640
- }
7641
- ];
7642
7787
  /** 读 llm-pi-ai 设置的 `providers` 字典(`<route> → { apiKeyEnv?, baseURL?, displayName? }`)。
7643
7788
  * 余额与订阅查询复用同一份来源:部署为某个 provider 配一次,多 surface 共享。
7644
7789
  * @param settings - the settings service (reads the llm-pi-ai namespace).
@@ -7651,10 +7796,12 @@ async function readPiAiProviders(settings) {
7651
7796
  for (const [route, entry] of Object.entries(providers ?? {})) {
7652
7797
  if (entry === null || typeof entry !== "object") continue;
7653
7798
  const { apiKeyEnv, baseURL, displayName } = entry;
7799
+ const models = entry.models;
7654
7800
  out[route] = {
7655
7801
  ...typeof apiKeyEnv === "string" ? { apiKeyEnv } : {},
7656
7802
  ...typeof baseURL === "string" ? { baseURL } : {},
7657
- ...typeof displayName === "string" ? { displayName } : {}
7803
+ ...typeof displayName === "string" ? { displayName } : {},
7804
+ ...Array.isArray(models) && models.length > 0 ? { hasModels: true } : {}
7658
7805
  };
7659
7806
  }
7660
7807
  return out;
@@ -7761,51 +7908,47 @@ function buildWorkspaceTitleResolver(ctx) {
7761
7908
  };
7762
7909
  }
7763
7910
  /**
7764
- * 解析订阅适配器需要的 API Key:从 llm-pi-ai 设置的 `providers.<id>.apiKeyEnv`
7765
- * 读引用(如 kimi-coding → KIMI_CODING_API_KEY),再经凭据 seam 解析成实际值。
7766
- * 同时识别出用户配置了 key 的订阅套餐(供面板只显示已识别的)。
7767
- * @param settings - the settings service (reads the llm-pi-ai namespace).
7768
- * @param credentials - the credentials service (resolves the env refs).
7911
+ * 凭据 seam 的宽容解析:ref 名 → 非空值;失败/缺失返回 undefined(多 surface 共用,
7912
+ * 失败按结构化静默记录)。
7769
7913
  */
7770
- async function resolveSubscriptionKeys(settings, credentials) {
7771
- const keys = { ...EMPTY_SUBSCRIPTION_KEYS };
7772
- let providers;
7773
- try {
7774
- providers = (settings.describe({ redactSecrets: true }).find((descriptor) => descriptor.ns === "llm-pi-ai")?.value)?.providers;
7775
- } catch {
7776
- return {
7777
- keys,
7778
- identified: []
7779
- };
7780
- }
7781
- for (const { provider, key } of SUBSCRIPTION_KEY_SOURCES) {
7782
- const env = providers?.[provider]?.apiKeyEnv;
7783
- if (typeof env !== "string" || env === "") continue;
7914
+ function refResolver(credentials) {
7915
+ return async (name) => {
7784
7916
  try {
7785
- const resolved = await credentials.resolve(credentialRef(env));
7786
- if (resolved?.value !== void 0 && resolved.value !== "") keys[key] = resolved.value;
7917
+ const resolved = await credentials.resolve(credentialRef(name));
7918
+ return resolved?.value !== void 0 && resolved.value !== "" ? resolved.value : void 0;
7787
7919
  } catch (error) {
7788
7920
  swallowed("credential-resolve-failed", error);
7921
+ return;
7789
7922
  }
7790
- }
7791
- if (providers?.["zai-coding-cn"]?.apiKeyEnv !== void 0 && keys.zaiCnApiKey !== "") keys.zaiRegion = "bigmodel-cn";
7923
+ };
7924
+ }
7925
+ /**
7926
+ * 解析订阅适配器需要的 API Key:经典形态从 llm-pi-ai 设置的 `providers.<id>.apiKeyEnv`
7927
+ * 读引用(如 kimi-coding → KIMI_CODING_API_KEY);新版宿主设置页把 key 直接存成
7928
+ * 凭据 refs(路由约定名 `<ROUTE>_API_KEY`,pi-ai 目录 ambient 名补充),显式引用
7929
+ * 解析不到时按约定名找回。同时识别出已配置的订阅套餐(供面板只显示已识别的)。
7930
+ * @param settings - the settings service (reads the llm-pi-ai namespace).
7931
+ * @param credentials - the credentials service (resolves the env refs).
7932
+ */
7933
+ async function resolveSubscriptionKeys(settings, credentials) {
7934
+ const providers = await readPiAiProviders(settings);
7935
+ const { keys, keyConfigured } = await resolveSubscriptionAccess(providers, refResolver(credentials));
7936
+ if (keys.zaiCnApiKey !== "") keys.zaiRegion = "bigmodel-cn";
7792
7937
  if (keys.opencodeApiKey === "") keys.opencodeApiKey = await readOpenCodeToken();
7793
7938
  keys.anthropicApiKey = await readClaudeOAuthToken();
7794
- if (keys.anthropicApiKey !== "") {
7795
- const identified = identifySubscriptionPlans(providers);
7796
- if (!identified.some((plan) => plan.provider === "anthropic")) identified.push({
7797
- provider: "anthropic",
7798
- displayName: "Claude (Anthropic)",
7799
- adapter: true
7800
- });
7801
- return {
7802
- keys,
7803
- identified
7804
- };
7805
- }
7939
+ const identified = identifySubscriptionPlans(Object.fromEntries(Object.entries(providers).map(([route, entry]) => [route, {
7940
+ ...entry.apiKeyEnv !== void 0 ? { apiKeyEnv: entry.apiKeyEnv } : {},
7941
+ ...entry.hasModels === true ? { hasModels: true } : {},
7942
+ ...keyConfigured.has(route) ? { refResolved: true } : {}
7943
+ }])));
7944
+ if (keys.anthropicApiKey !== "" && !identified.some((plan) => plan.provider === "anthropic")) identified.push({
7945
+ provider: "anthropic",
7946
+ displayName: "Claude (Anthropic)",
7947
+ adapter: true
7948
+ });
7806
7949
  return {
7807
7950
  keys,
7808
- identified: identifySubscriptionPlans(providers)
7951
+ identified
7809
7952
  };
7810
7953
  }
7811
7954
  /** 读本机 OpenCode 客户端的凭据 token;取不到返回空串(安静退回)。 */
@@ -8341,14 +8484,18 @@ function apply(ctx, config = {}) {
8341
8484
  };
8342
8485
  const refreshRelay = async () => {
8343
8486
  const providers = await readPiAiProviders(ctx.settings);
8487
+ const resolve = refResolver(ctx.credentials);
8344
8488
  const routes = [];
8345
8489
  for (const [route, entry] of Object.entries(providers)) {
8346
- if (entry.baseURL === void 0 || entry.apiKeyEnv === void 0) continue;
8490
+ if (entry.baseURL === void 0) continue;
8347
8491
  if (isOfficialBaseUrl(entry.baseURL)) continue;
8492
+ const apiKeyRefs = [.../* @__PURE__ */ new Set([...entry.apiKeyEnv !== void 0 ? [entry.apiKeyEnv] : [], ...routeKeyRefCandidates(route)])];
8493
+ const hit = await firstResolvedRef(apiKeyRefs, resolve);
8494
+ if (entry.apiKeyEnv === void 0 && hit === void 0) continue;
8348
8495
  routes.push({
8349
8496
  route,
8350
8497
  baseURL: entry.baseURL,
8351
- apiKeyEnv: entry.apiKeyEnv,
8498
+ apiKeyRefs,
8352
8499
  ...entry.displayName === void 0 ? {} : { displayName: entry.displayName }
8353
8500
  });
8354
8501
  }
@@ -8382,4 +8529,4 @@ function apply(ctx, config = {}) {
8382
8529
  });
8383
8530
  }
8384
8531
  //#endregion
8385
- export { adaptSessionPersistence, apply, createFileUsageLedgerStore, guardLoopback, hostNameOf, inject, isLoopbackHost, isLoopbackOrigin, isLoopbackPeer, normalizeTrustedHosts, readPiAiProviderRoutes, readRegisteredProviderIds, resolveSubscriptionKeys };
8532
+ export { Config, adaptSessionPersistence, apply, createFileUsageLedgerStore, guardLoopback, hostNameOf, inject, isLoopbackHost, isLoopbackOrigin, isLoopbackPeer, normalizeTrustedHosts, readPiAiProviderRoutes, readRegisteredProviderIds, resolveSubscriptionKeys };