@kenz1117/dsh-ui-usage-billing 0.9.19 → 0.9.20

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
@@ -1,7 +1,7 @@
1
1
  import { createRequire } from "node:module";
2
- import { readFile, stat } from "node:fs/promises";
2
+ import { mkdir, readFile, stat } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
- import { join } from "node:path";
4
+ import { dirname, join } from "node:path";
5
5
  import { defineTool } from "@deepseek-ai/dsh-tools";
6
6
  import { withFileLock, writeFileAtomic } from "@deepseek-ai/dsh-atomic-write";
7
7
  import { credentialRef } from "@deepseek-ai/dsh-credentials";
@@ -184,7 +184,7 @@ let liveExtraModels;
184
184
  * @param pricing - the `/api/billing/pricing` response.
185
185
  */
186
186
  function applyLivePricing(pricing) {
187
- liveRate = pricing.rate;
187
+ liveRate = typeof pricing.rate === "number" && Number.isFinite(pricing.rate) && pricing.rate > 0 ? pricing.rate : void 0;
188
188
  livePrices = pricing.prices;
189
189
  liveExtraModels = pricing.extraModels;
190
190
  }
@@ -742,7 +742,10 @@ const MODEL_KEY_ALIASES = {
742
742
  "step-3.7-flash": "step",
743
743
  "seed-2.0-mini": "doubao-mini",
744
744
  "k3": "kimi-k3",
745
- "kimi-k3": "kimi-k3"
745
+ "kimi-k3": "kimi-k3",
746
+ "minimax-m1": "minimax",
747
+ "minimax-m2": "minimax",
748
+ "minimax-m3": "minimax"
746
749
  };
747
750
  /**
748
751
  * 模型 id 归一化:小写、去括号附注(如 `gpt5.6 luna(go)` 只看主体)、再去所有
@@ -892,7 +895,7 @@ function computeCost(entry, buckets, peakShare = DEFAULT_PEAK_SHARE) {
892
895
  * @param buckets - token usage counts.
893
896
  * @param timeMs - the call's wall-clock time (epoch ms); null falls back to the peak-share mix.
894
897
  * @param peakShare - fallback mix used only when `timeMs` is missing.
895
- * @returns the estimated cost in the entry's native currency.
898
+ * @returns the estimated cost in CNY(USD 计价模型已按当前汇率折算)。
896
899
  */
897
900
  function computeCostAt(entry, buckets, timeMs, peakShare = DEFAULT_PEAK_SHARE) {
898
901
  if (entry.price.offPeak === void 0) return priceBandCost(entry.price, buckets, entry.price.currency);
@@ -1457,11 +1460,12 @@ function createUsageAggregator(persistence, options = {}) {
1457
1460
  console.warn("[usage-billing] failed to load durable usage ledger; rebuilding from current sessions:", error);
1458
1461
  }
1459
1462
  };
1460
- /** 失效键:日志文件的 mtime+size;拿不到(后端无 locate / 文件丢失)时每次重折。 */
1463
+ /** 失效键:日志文件的 mtime+size;拿不到(后端无 locate / 文件丢失 / locate 抛错)时返回 null,
1464
+ * 让调用方每次重折。locate 调用也纳入 try,避免单个会话的 locate 异常把整份聚合拖垮。 */
1461
1465
  const stampOf = async (meta) => {
1462
- const location = persistence.locate?.(meta);
1463
- if (location === void 0) return null;
1464
1466
  try {
1467
+ const location = persistence.locate?.(meta);
1468
+ if (location === void 0) return null;
1465
1469
  const info = await stat(location.path);
1466
1470
  return `${String(info.mtimeMs)}:${String(info.size)}`;
1467
1471
  } catch {
@@ -2081,9 +2085,20 @@ const QUERIERS = [
2081
2085
  * @returns the balance rows (one per provider).
2082
2086
  */
2083
2087
  async function queryBalances(ctx, providers) {
2084
- return await Promise.all(QUERIERS.map(({ route, querier, displayName }) => {
2088
+ const byName = /* @__PURE__ */ new Map();
2089
+ for (const { route, querier, displayName } of QUERIERS) {
2085
2090
  const env = providers[route]?.apiKeyEnv;
2086
- if (typeof env !== "string" || env === "") return Promise.resolve({
2091
+ const configured = typeof env === "string" && env !== "";
2092
+ const existing = byName.get(displayName);
2093
+ if (existing !== void 0 && existing.env !== void 0) continue;
2094
+ byName.set(displayName, {
2095
+ displayName,
2096
+ querier,
2097
+ env: configured ? env : void 0
2098
+ });
2099
+ }
2100
+ return await Promise.all([...byName.values()].map(({ querier, displayName, env }) => {
2101
+ if (env === void 0) return Promise.resolve({
2087
2102
  provider: displayName,
2088
2103
  displayName,
2089
2104
  error: "unconfigured"
@@ -3213,15 +3228,16 @@ async function collectZai(keys, config, timeoutMs) {
3213
3228
  function goWindow(value, kind) {
3214
3229
  if (value === null || typeof value !== "object") return null;
3215
3230
  const record = value;
3216
- const percentSource = record.usagePercent ?? record.usedPercent ?? record.percentUsed ?? record.percentage ?? record.percent;
3217
- let usedPercent = clampPercent$1(numberOrNull$1(percentSource));
3231
+ const ratioSource = record.usagePercent ?? record.usedPercent ?? record.percentUsed ?? record.percentage;
3232
+ const fromRatioField = ratioSource !== void 0 && ratioSource !== null;
3233
+ let usedPercent = clampPercent$1(numberOrNull$1(fromRatioField ? ratioSource : record.percent));
3218
3234
  if (usedPercent === null) {
3219
3235
  const used = numberOrNull$1(record.used ?? record.consumed);
3220
3236
  const limit = numberOrNull$1(record.limit ?? record.total ?? record.quota);
3221
3237
  if (used !== null && limit !== null && limit > 0) usedPercent = clampPercent$1(used / limit * 100);
3222
3238
  }
3223
3239
  if (usedPercent === null) return null;
3224
- if (usedPercent <= 1 && usedPercent >= 0 && record.percent === void 0 && percentSource !== void 0) usedPercent *= 100;
3240
+ if (fromRatioField && usedPercent <= 1 && usedPercent >= 0) usedPercent *= 100;
3225
3241
  const resetSeconds = numberOrNull$1(record.resetInSec ?? record.resetInSeconds ?? record.resetSeconds);
3226
3242
  const resetsAt = resetSeconds === null ? toIso(record.resetAt ?? record.resetsAt ?? record.nextReset) : new Date(Date.now() + Math.max(0, resetSeconds) * 1e3).toISOString();
3227
3243
  return {
@@ -3329,6 +3345,18 @@ function resolveMiniMaxBaseUrl(config) {
3329
3345
  function minmaxDisplayName(provider) {
3330
3346
  return SUBSCRIPTION_DISPLAY_NAMES[provider] ?? "MiniMax Coding Plan";
3331
3347
  }
3348
+ /** MiniMax Token Plan / Coding Plan 的 key 判别:普通按量 API key 以 `sk-` 开头,
3349
+ * Token Plan(订阅套餐)key 以 `sk-cp-` 开头。据此区分一个 key 是订阅还是按量,
3350
+ * 供查询失败时给出「key 类型 / region」定向提示,避免把按量 key 误当 token-plan
3351
+ * 查询后只剩一句笼统的「响应异常」。 */
3352
+ function isMiniMaxTokenPlanKey(apiKey) {
3353
+ return apiKey.startsWith("sk-cp-");
3354
+ }
3355
+ /** 构造 MiniMax Token Plan 查询端点:避免 base 已含 `/v1` 时拼出 `/v1/v1/...`。 */
3356
+ function miniMaxEndpoint(base) {
3357
+ const trimmed = base.replace(/\/+$/, "");
3358
+ return trimmed.endsWith("/v1") ? `${trimmed}/token_plan/remains` : `${trimmed}/v1/token_plan/remains`;
3359
+ }
3332
3360
  /** Collect the MiniMax Token Plan quota (CN + INTL). */
3333
3361
  async function collectMiniMax(keys, config, timeoutMs) {
3334
3362
  const apiKey = keys.minmaxApiKey.trim();
@@ -3340,16 +3368,25 @@ async function collectMiniMax(keys, config, timeoutMs) {
3340
3368
  status: "not-configured",
3341
3369
  windows: []
3342
3370
  };
3371
+ const tokenPlanKey = isMiniMaxTokenPlanKey(apiKey);
3343
3372
  try {
3344
- const windows = parseMiniMaxRemains(await requestJson(`${base}/v1/token_plan/remains`, { headers: {
3373
+ const windows = parseMiniMaxRemains(await requestJson(miniMaxEndpoint(base), { headers: {
3345
3374
  authorization: `Bearer ${apiKey}`,
3346
3375
  accept: "application/json"
3347
3376
  } }, timeoutMs));
3377
+ if (windows.length > 0) return {
3378
+ provider: config.provider,
3379
+ displayName,
3380
+ status: "ok",
3381
+ windows,
3382
+ ...tokenPlanKey ? {} : { hint: "此 key 未以 sk-cp- 开头(可能为按量 API);但 Token Plan 接口返回了额度,故按 Token Plan 展示" }
3383
+ };
3348
3384
  return {
3349
3385
  provider: config.provider,
3350
3386
  displayName,
3351
- status: windows.length > 0 ? "ok" : "invalid-response",
3352
- windows
3387
+ status: "invalid-response",
3388
+ windows,
3389
+ hint: tokenPlanKey ? "Token Plan key 未返回额度:可能是 region 不匹配,请在 provider 的 baseURL 指向国内 api.minimaxi.com 或国际 api.minimax.io" : "此 key 未以 sk-cp- 开头,可能为按量 API 而非 Token Plan;如需订阅额度请用 sk-cp- 开头的 Token Plan key"
3353
3390
  };
3354
3391
  } catch (error) {
3355
3392
  return {
@@ -3628,6 +3665,19 @@ function endpointOf(baseURL, path) {
3628
3665
  return new URL(path, baseURL).toString();
3629
3666
  }
3630
3667
  /**
3668
+ * 用已知程序类型查询该路由的额度(用当前 key)。额度按 key 独立,因此即便
3669
+ * 指纹缓存已识别出站点类型,也仍要实际发一次请求读出本 key 的余额/窗口。
3670
+ * @param baseURL - 站点端点。
3671
+ * @param apiKey - 已解析的本路由 key。
3672
+ * @param kind - 已识别出的程序类型。
3673
+ * @returns 解析出的余额/窗口;取不到时返回 null(漂移或端点不可用)。
3674
+ */
3675
+ async function readRelayByKind(baseURL, apiKey, kind) {
3676
+ const res = await fetchRelayJson(endpointOf(baseURL, kind === "sub2api" ? "/v1/usage" : "/api/status"), apiKey);
3677
+ if (!res.ok) return null;
3678
+ return kind === "sub2api" ? parseSub2ApiUsage(res.data) : parseNewApiStatus(res.data);
3679
+ }
3680
+ /**
3631
3681
  * 查询单个中转站路由的额度。先试 Sub2API,再试 New API;任一读出额度即返回。
3632
3682
  * @param ctx - host context carrying the credentials seam.
3633
3683
  * @param route - 待探测的路由(baseURL + apiKeyEnv)。
@@ -3645,19 +3695,43 @@ async function queryRelayQuota(ctx, route) {
3645
3695
  kind: "unknown",
3646
3696
  status: "unavailable"
3647
3697
  };
3648
- const hit = await ctx.credentials.resolve(credentialRef(route.apiKeyEnv));
3698
+ let hit;
3699
+ try {
3700
+ hit = await ctx.credentials.resolve(credentialRef(route.apiKeyEnv));
3701
+ } catch {
3702
+ return {
3703
+ ...base,
3704
+ kind: "unknown",
3705
+ status: "not-configured"
3706
+ };
3707
+ }
3649
3708
  if (hit === void 0 || hit.value === "") return {
3650
3709
  ...base,
3651
3710
  kind: "unknown",
3652
3711
  status: "not-configured"
3653
3712
  };
3654
3713
  const cached = fingerprintCache.get(base.origin);
3655
- if (cached !== void 0 && Date.now() - cached.at < FINGERPRINT_TTL_MS) return {
3656
- ...base,
3657
- kind: cached.kind,
3658
- status: "ok"
3659
- };
3714
+ const knownKind = cached !== void 0 && Date.now() - cached.at < FINGERPRINT_TTL_MS ? cached.kind : void 0;
3660
3715
  try {
3716
+ if (knownKind === "sub2api" || knownKind === "new-api") {
3717
+ const parsed = await readRelayByKind(route.baseURL, hit.value, knownKind);
3718
+ if (parsed !== null) {
3719
+ relayGate.success(gateKey);
3720
+ return {
3721
+ ...base,
3722
+ kind: knownKind,
3723
+ status: "ok",
3724
+ ...parsed.balance !== void 0 ? { balance: parsed.balance } : {},
3725
+ ...parsed.windows !== void 0 ? { windows: parsed.windows } : {}
3726
+ };
3727
+ }
3728
+ relayGate.fail(gateKey);
3729
+ return {
3730
+ ...base,
3731
+ kind: knownKind,
3732
+ status: "invalid-response"
3733
+ };
3734
+ }
3661
3735
  const sub2 = await fetchRelayJson(endpointOf(route.baseURL, "/v1/usage"), hit.value);
3662
3736
  if (sub2.ok) {
3663
3737
  const parsed = parseSub2ApiUsage(sub2.data);
@@ -3682,23 +3756,7 @@ async function queryRelayQuota(ctx, route) {
3682
3756
  status: "invalid-response"
3683
3757
  };
3684
3758
  }
3685
- if (sub2.status === 401 || sub2.status === 403) {
3686
- relayGate.fail(gateKey);
3687
- return {
3688
- ...base,
3689
- kind: "unknown",
3690
- status: "unauthorized"
3691
- };
3692
- }
3693
3759
  const na = await fetchRelayJson(endpointOf(route.baseURL, "/api/status"), hit.value);
3694
- if (na.status === 401 || na.status === 403) {
3695
- relayGate.fail(gateKey);
3696
- return {
3697
- ...base,
3698
- kind: "unknown",
3699
- status: "unauthorized"
3700
- };
3701
- }
3702
3760
  if (na.ok) {
3703
3761
  const parsed = parseNewApiStatus(na.data);
3704
3762
  if (parsed !== null) {
@@ -3728,11 +3786,12 @@ async function queryRelayQuota(ctx, route) {
3728
3786
  status: "unavailable"
3729
3787
  };
3730
3788
  } catch (error) {
3731
- relayGate.fail(gateKey);
3789
+ const status = statusOf(error);
3790
+ if (status !== "unauthorized") relayGate.fail(gateKey);
3732
3791
  return {
3733
3792
  ...base,
3734
3793
  kind: "unknown",
3735
- status: statusOf(error)
3794
+ status
3736
3795
  };
3737
3796
  }
3738
3797
  }
@@ -3779,12 +3838,26 @@ function isLoopbackPeer(req) {
3779
3838
  if (address === void 0) return false;
3780
3839
  return address === "::1" || address.startsWith("127.") || address.startsWith("::ffff:127.");
3781
3840
  }
3782
- /** 校验 Host 头是本机回环(127.x / ::1 / localhost 或空,供 curl 不带 Host 的极简请求)。 */
3841
+ /** 校验 Host 头是本机回环(精确 127.0.0.0/8 / ::1 / localhost 或空,供 curl 不带 Host 的极简请求)。
3842
+ * 拒绝 `127.0.0.1.attacker.com` 这类以 `127.` 开头但解析到外部的 DNS rebinding 域名:
3843
+ * 只用 `startsWith('127.')` 会被它穿透,必须精确匹配回环 IP 的字面量。 */
3783
3844
  function isLoopbackHost(req) {
3784
3845
  const host = req.headers.host;
3785
3846
  if (host === void 0 || host === "") return true;
3786
3847
  const name = host.split(":")[0];
3787
- return name === "localhost" || name === "::1" || name !== void 0 && name.startsWith("127.");
3848
+ return name === "localhost" || name === "::1" || name !== void 0 && /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(name);
3849
+ }
3850
+ /** 校验 Origin 头是否回环(写操作用,防止跨站表单/fetch 改写设置)。Origin 缺失
3851
+ * (curl 或同源 fetch 不带)视为放行,交给下方的 Content-Type 校验兜底;Origin 存在
3852
+ * 但主机非回环则拒绝——跨站脚本发起的写请求必带攻击者域名的 Origin。 */
3853
+ function isLoopbackOrigin(origin) {
3854
+ if (origin === void 0 || origin === "") return true;
3855
+ try {
3856
+ const host = new URL(origin).hostname;
3857
+ return host === "localhost" || host === "::1" || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host);
3858
+ } catch {
3859
+ return false;
3860
+ }
3788
3861
  }
3789
3862
  /**
3790
3863
  * 回环防护守卫:仅接受回环 GET 请求(peer socket 地址 + Host 头同时校验)。
@@ -3849,6 +3922,10 @@ function createFileUsageLedgerStore(ledgerPath) {
3849
3922
  } catch {}
3850
3923
  },
3851
3924
  async save(document) {
3925
+ await mkdir(dirname(ledgerPath), {
3926
+ recursive: true,
3927
+ mode: 448
3928
+ });
3852
3929
  await withFileLock(ledgerPath, async () => {
3853
3930
  try {
3854
3931
  const existing = await readFile(ledgerPath, "utf8");
@@ -4296,7 +4373,11 @@ function apply(ctx, config = {}) {
4296
4373
  res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
4297
4374
  const piProviders = await readPiAiProviders(ctx.settings);
4298
4375
  const providers = { ...piProviders };
4299
- if (providers["deepseek"] === void 0) providers["deepseek"] = { apiKeyEnv: config.balanceApiKeyEnv ?? DEFAULT_BALANCE_API_KEY_ENV };
4376
+ const deepseekKey = providers["deepseek"]?.apiKeyEnv;
4377
+ if (deepseekKey === void 0 || deepseekKey === "") providers["deepseek"] = {
4378
+ ...providers["deepseek"],
4379
+ apiKeyEnv: config.balanceApiKeyEnv ?? DEFAULT_BALANCE_API_KEY_ENV
4380
+ };
4300
4381
  const balances = await queryBalances(ctx, providers);
4301
4382
  const custom = await queryCustomBalances(ctx, config.customBalances ?? []);
4302
4383
  const declared = await queryDeclaredEndpoints(ctx, piProviders, config.declaredEndpoints ?? []);
@@ -4348,9 +4429,25 @@ function apply(ctx, config = {}) {
4348
4429
  res.end(JSON.stringify({ error: "method not allowed" }));
4349
4430
  return;
4350
4431
  }
4432
+ if (!isLoopbackOrigin(req.headers.origin)) {
4433
+ res.writeHead(403, { "content-type": "application/json; charset=utf-8" });
4434
+ res.end(JSON.stringify({ error: "forbidden: loopback only" }));
4435
+ return;
4436
+ }
4437
+ if (!(req.headers["content-type"] ?? "").toLowerCase().includes("application/json")) {
4438
+ res.writeHead(415, { "content-type": "application/json; charset=utf-8" });
4439
+ res.end(JSON.stringify({ error: "unsupported content-type" }));
4440
+ return;
4441
+ }
4351
4442
  try {
4352
4443
  let body = "";
4353
- for await (const chunk of req) body += String(chunk);
4444
+ for await (const chunk of req) {
4445
+ body += String(chunk);
4446
+ if (body.length > 512) {
4447
+ res.end(JSON.stringify({ error: "body too large" }));
4448
+ return;
4449
+ }
4450
+ }
4354
4451
  const parsed = JSON.parse(body === "" ? "{}" : body);
4355
4452
  if (usageSettingsScope === void 0) {
4356
4453
  res.end(JSON.stringify({ error: "settings unavailable" }));
@@ -4373,10 +4470,15 @@ function apply(ctx, config = {}) {
4373
4470
  };
4374
4471
  const refreshQuotas = async () => {
4375
4472
  const { keys, identified } = await resolveSubscriptionKeys(ctx.settings, ctx.credentials);
4376
- const rows = [...await collectSubscriptions(keys, identified.filter((item) => item.adapter).map((item) => ({
4377
- provider: item.provider,
4378
- ...item.region === void 0 ? {} : { region: item.region }
4379
- })))].map((row) => {
4473
+ const piProviders = await readPiAiProviders(ctx.settings);
4474
+ const rows = [...await collectSubscriptions(keys, identified.filter((item) => item.adapter).map((item) => {
4475
+ const baseURL = piProviders[item.provider]?.baseURL;
4476
+ return {
4477
+ provider: item.provider,
4478
+ ...item.region === void 0 ? {} : { region: item.region },
4479
+ ...typeof baseURL === "string" && baseURL !== "" ? { baseUrl: baseURL } : {}
4480
+ };
4481
+ }))].map((row) => {
4380
4482
  const planType = planTypeOf(row.provider);
4381
4483
  const subscriptionAmount = subscriptionFeeCnyOf(row.provider, live.rate);
4382
4484
  return {
@@ -4404,7 +4506,11 @@ function apply(ctx, config = {}) {
4404
4506
  handler: async (req, res) => {
4405
4507
  if (!guardLoopback(req, res)) return;
4406
4508
  res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
4407
- if (Date.now() - quotaCache.at >= SUBSCRIPTION_CACHE_MS) await refreshQuotas();
4509
+ if (Date.now() - quotaCache.at >= SUBSCRIPTION_CACHE_MS) try {
4510
+ await refreshQuotas();
4511
+ } catch (error) {
4512
+ console.warn("[usage-billing] refreshQuotas failed; serving cached quotas:", error);
4513
+ }
4408
4514
  res.end(JSON.stringify({ quotas: quotaCache.quotas }));
4409
4515
  }
4410
4516
  }), "usage-billing: subscriptions route");
@@ -4436,7 +4542,11 @@ function apply(ctx, config = {}) {
4436
4542
  handler: async (req, res) => {
4437
4543
  if (!guardLoopback(req, res)) return;
4438
4544
  res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
4439
- if (Date.now() - relayCache.at >= SUBSCRIPTION_CACHE_MS) await refreshRelay();
4545
+ if (Date.now() - relayCache.at >= SUBSCRIPTION_CACHE_MS) try {
4546
+ await refreshRelay();
4547
+ } catch (error) {
4548
+ console.warn("[usage-billing] refreshRelay failed; serving cached relay quotas:", error);
4549
+ }
4440
4550
  const diagnostics = relayCache.quotas.map((row) => ({
4441
4551
  route: row.route,
4442
4552
  origin: row.origin,
@@ -6,7 +6,6 @@
6
6
  * 3. Token 结构 KPI(缓存命中率 / reasoning 占比 / 输入:输出比 / 峰值日)。
7
7
  */
8
8
  import type { UsageBillingKey } from './locales.ts';
9
- import { type CostCurrency } from './pricing.ts';
10
9
  import type { UsageStats } from './UsageBilling.tsx';
11
10
  /**
12
11
  * Token 洞察面板。
@@ -16,7 +15,6 @@ import type { UsageStats } from './UsageBilling.tsx';
16
15
  */
17
16
  export declare function TokenPanel(props: {
18
17
  stats: UsageStats;
19
- currency: CostCurrency;
20
18
  trendDays: 7 | 30;
21
19
  onTrendDays: (d: 7 | 30) => void;
22
20
  t: (key: UsageBillingKey) => string;
@@ -223,6 +223,7 @@ export interface UsageStats {
223
223
  }
224
224
  /**
225
225
  * 拉取官方余额差对账提示(drift 时非空),供余额面板展示;失败返回 undefined。
226
+ * 复用 {@link fetchBalanceDoc} 的同一响应,导出供对账提示渲染测试单独解析。
226
227
  * @returns the reconcile notice, or undefined on any failure / no drift.
227
228
  */
228
229
  export declare function fetchReconcile(): Promise<ReconcileNotice | undefined>;
@@ -225,10 +225,11 @@ export declare function computeCost(entry: ModelEntry, buckets: TokenUsageBucket
225
225
  * @param buckets - token usage counts.
226
226
  * @param timeMs - the call's wall-clock time (epoch ms); null falls back to the peak-share mix.
227
227
  * @param peakShare - fallback mix used only when `timeMs` is missing.
228
- * @returns the estimated cost in the entry's native currency.
228
+ * @returns the estimated cost in CNY(USD 计价模型已按当前汇率折算)。
229
229
  */
230
230
  export declare function computeCostAt(entry: ModelEntry, buckets: TokenUsageBuckets, timeMs: number | null | undefined, peakShare?: number): number;
231
- /** 人民币 → 美元(显示换算用):1 USD = {@link USD_TO_CNY} CNY。 */
231
+ /** 人民币 → 美元(显示换算用):用当前生效汇率(实时优先,缺失回退内置),
232
+ * 与计价链路的 `currentRate()` 同口径,避免实时汇率生效时 USD 显示与计价不一致。 */
232
233
  export declare function cnyToUsd(cny: number): number;
233
234
  /**
234
235
  * Format an amount with adaptive precision and the given currency symbol.
@@ -33,7 +33,8 @@ export interface FloatWindowPrefs {
33
33
  export declare const DEFAULT_FLOAT_WINDOW_PREFS: FloatWindowPrefs;
34
34
  /** localStorage key(与 budget store 的 `dsh.ui-usage-billing.*` 命名空间一致)。 */
35
35
  export declare const FLOAT_WINDOW_STORAGE_KEY = "dsh.ui-usage-billing.float";
36
- /** 读取浮窗偏好(含损坏/缺失回退到默认)。仅在浏览器半区调用。 */
36
+ /** 读取浮窗偏好(含损坏/缺失回退到默认)。仅在浏览器半区调用。
37
+ * 返回全新对象(含 targets 数组拷贝),避免调用方就地修改污染共享默认值。 */
37
38
  export declare function loadFloatWindowPrefs(): FloatWindowPrefs;
38
39
  /** 写入浮窗偏好。失败静默(展示偏好非关键)。 */
39
40
  export declare function saveFloatWindowPrefs(prefs: FloatWindowPrefs): void;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kenz1117/dsh-ui-usage-billing",
3
3
  "description": "Usage billing dashboard for DeepSeek Harness: sidebar cost metrics plus a full dashboard modal, priced from a current multi-provider catalog with real usage aggregated from session logs.",
4
- "version": "0.9.19",
4
+ "version": "0.9.20",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -47,42 +47,42 @@
47
47
  },
48
48
  "license": "MIT",
49
49
  "peerDependencies": {
50
- "@deepseek-ai/dsh-api-remotes": "workspace:^",
51
- "@deepseek-ai/dsh-atomic-write": "workspace:^",
52
- "@deepseek-ai/dsh-client-connection": "workspace:^",
53
- "@deepseek-ai/dsh-host-webserver": "workspace:^",
54
- "@deepseek-ai/dsh-client-locale": "workspace:^",
55
- "@deepseek-ai/dsh-client-runtime": "workspace:^",
56
- "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
57
- "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
58
- "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
59
- "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
60
- "@deepseek-ai/dsh-credentials": "workspace:^",
61
- "@deepseek-ai/dsh-invariants": "workspace:^",
62
- "@deepseek-ai/dsh-llm": "workspace:^",
63
- "@deepseek-ai/dsh-session": "workspace:^",
64
- "@deepseek-ai/dsh-session-persistence": "workspace:^",
65
- "@deepseek-ai/dsh-settings": "workspace:^",
66
- "@deepseek-ai/dsh-tools": "workspace:^",
67
- "@deepseek-ai/schemastery": "workspace:^",
68
- "@deepseek-ai/cordis": "workspace:^",
50
+ "@deepseek-ai/dsh-api-remotes": "*",
51
+ "@deepseek-ai/dsh-atomic-write": "*",
52
+ "@deepseek-ai/dsh-client-connection": "*",
53
+ "@deepseek-ai/dsh-host-webserver": "*",
54
+ "@deepseek-ai/dsh-client-locale": "*",
55
+ "@deepseek-ai/dsh-client-runtime": "*",
56
+ "@deepseek-ai/dsh-client-ui-conversation": "*",
57
+ "@deepseek-ai/dsh-client-ui-sidebar": "*",
58
+ "@deepseek-ai/dsh-client-ui-primitives": "*",
59
+ "@deepseek-ai/dsh-client-ui-slots": "*",
60
+ "@deepseek-ai/dsh-credentials": "*",
61
+ "@deepseek-ai/dsh-invariants": "*",
62
+ "@deepseek-ai/dsh-llm": "*",
63
+ "@deepseek-ai/dsh-session": "*",
64
+ "@deepseek-ai/dsh-session-persistence": "*",
65
+ "@deepseek-ai/dsh-settings": "*",
66
+ "@deepseek-ai/dsh-tools": "*",
67
+ "@deepseek-ai/schemastery": "*",
68
+ "@deepseek-ai/cordis": "*",
69
69
  "react": "^18.2.0"
70
70
  },
71
71
  "dependencies": {
72
72
  "clsx": "^2.1.1"
73
73
  },
74
74
  "devDependencies": {
75
- "@deepseek-ai/dsh-client-locale": "workspace:^",
76
- "@deepseek-ai/dsh-client-runtime": "workspace:^",
77
- "@deepseek-ai/dsh-client-test-runtime": "workspace:^",
78
- "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
79
- "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
80
- "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
81
- "@deepseek-ai/dsh-invariants": "workspace:^",
75
+ "@deepseek-ai/dsh-client-locale": "*",
76
+ "@deepseek-ai/dsh-client-runtime": "*",
77
+ "@deepseek-ai/dsh-client-test-runtime": "*",
78
+ "@deepseek-ai/dsh-client-ui-conversation": "*",
79
+ "@deepseek-ai/dsh-client-ui-primitives": "*",
80
+ "@deepseek-ai/dsh-client-ui-slots": "*",
81
+ "@deepseek-ai/dsh-invariants": "*",
82
82
  "@testing-library/react": "^16.1.0",
83
83
  "@types/react": "~18.3.1",
84
84
  "@types/node": "^22.20.0",
85
- "@deepseek-ai/cordis": "workspace:^",
85
+ "@deepseek-ai/cordis": "*",
86
86
  "react": "^18.2.0",
87
87
  "react-dom": "^18.2.0",
88
88
  "lightningcss": "^1.32.0",