@kenz1117/dsh-ui-usage-billing 0.6.0 → 0.7.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/lib/index.js CHANGED
@@ -4,9 +4,23 @@ import { join } from "node:path";
4
4
  import { defineTool } from "@deepseek-ai/dsh-tools";
5
5
  import { writeFileAtomic } from "@deepseek-ai/dsh-atomic-write";
6
6
  import { credentialRef } from "@deepseek-ai/dsh-credentials";
7
+ /** 运行时实时覆盖:undefined = 用内置目录与内置汇率(默认值降级)。 */
8
+ let liveRate;
9
+ let livePrices;
10
+ let liveExtraModels;
11
+ /**
12
+ * Apply the node half's live pricing snapshot. Absent fields keep the
13
+ * built-in catalog and rate; callers never fabricate values.
14
+ * @param pricing - the `/api/billing/pricing` response.
15
+ */
16
+ function applyLivePricing(pricing) {
17
+ liveRate = pricing.rate;
18
+ livePrices = pricing.prices;
19
+ liveExtraModels = pricing.extraModels;
20
+ }
7
21
  /** 当前汇率:实时覆盖优先,缺省回退内置固定值。 */
8
22
  function currentRate() {
9
- return 6.79;
23
+ return liveRate ?? 6.79;
10
24
  }
11
25
  /** Default share of traffic assumed to fall in the peak band (0..1). */
12
26
  const DEFAULT_PEAK_SHARE = .5;
@@ -544,11 +558,48 @@ const MODEL_KEY_ALIASES = {
544
558
  /** Lookup a model by its stats key; falls back to the generic `other` entry. */
545
559
  function modelOf(key) {
546
560
  const resolved = MODEL_KEY_ALIASES[key] ?? key;
547
- return MODEL_CATALOG.find((entry) => entry.key === resolved) ?? (() => {
561
+ const found = MODEL_CATALOG.find((entry) => entry.key === resolved);
562
+ const extra = liveExtraModels?.find((item) => item.key === resolved);
563
+ const base = found ?? (extra !== void 0 ? extraEntryOf(extra) : (() => {
548
564
  const fallback = MODEL_CATALOG.at(-1);
549
565
  if (fallback !== void 0) return fallback;
550
566
  throw new Error("MODEL_CATALOG must not be empty");
551
- })();
567
+ })());
568
+ const live = livePrices?.[resolved];
569
+ if (live === void 0) return base;
570
+ return {
571
+ ...base,
572
+ price: {
573
+ currency: "USD",
574
+ input: live.input,
575
+ cacheHit: live.cacheHit,
576
+ output: live.output
577
+ }
578
+ };
579
+ }
580
+ /** models.dev 补充条目转为目录条目:USD 直价(走汇率换算),无峰谷分档。 */
581
+ function extraEntryOf(extra) {
582
+ return {
583
+ key: extra.key,
584
+ name: extra.name,
585
+ provider: extra.provider,
586
+ colorVar: "dsw-static-neutral-400",
587
+ price: {
588
+ currency: "USD",
589
+ input: extra.price.input,
590
+ cacheHit: extra.price.cacheHit,
591
+ output: extra.price.output
592
+ }
593
+ };
594
+ }
595
+ /**
596
+ * 模型是否可计价:内置目录或 models.dev 补充条目命中。聚合层的计价闸门
597
+ * (目录外模型不产生费用,避免兜底档误估)。
598
+ */
599
+ function isPriced(key) {
600
+ const resolved = MODEL_KEY_ALIASES[key] ?? key;
601
+ if (MODEL_CATALOG.some((entry) => entry.key === resolved)) return true;
602
+ return (liveExtraModels ?? []).some((item) => item.key === resolved);
552
603
  }
553
604
  /**
554
605
  * Price one band's token usage in CNY. The stats `input` field is the TOTAL
@@ -681,7 +732,7 @@ function foldUsage(acc, usage, key, subscription, timeMs) {
681
732
  acc.output += usage.outputTokens;
682
733
  acc.cacheHit += cacheHit;
683
734
  acc.cacheMiss += cacheMiss;
684
- if (!subscription && MODEL_CATALOG.some((entry) => entry.key === key)) acc.cost += computeCostAt(modelOf(key), {
735
+ if (!subscription && isPriced(key)) acc.cost += computeCostAt(modelOf(key), {
685
736
  input: cacheHit + cacheMiss,
686
737
  cacheHit,
687
738
  cacheMiss,
@@ -824,7 +875,7 @@ function foldSession(events, subscriptionProviders) {
824
875
  state.output += usage.outputTokens;
825
876
  state.cacheHit += usage.cacheReadTokens ?? 0;
826
877
  state.cacheMiss += usage.inputTokens + (usage.cacheWriteTokens ?? 0);
827
- if (!subscription && MODEL_CATALOG.some((entry) => entry.key === modelKey)) {
878
+ if (!subscription && isPriced(modelKey)) {
828
879
  const buckets = {
829
880
  input: (usage.cacheReadTokens ?? 0) + usage.inputTokens + (usage.cacheWriteTokens ?? 0),
830
881
  cacheHit: usage.cacheReadTokens ?? 0,
@@ -1198,6 +1249,125 @@ async function queryBalances(ctx, providers) {
1198
1249
  return querier(ctx, env);
1199
1250
  }));
1200
1251
  }
1252
+ /** 点路径取值:`data.total_available` → 逐层下钻;任一缺失返回 undefined。 */
1253
+ function getPath(data, path) {
1254
+ let cursor = data;
1255
+ for (const segment of path.split(".")) {
1256
+ if (cursor === null || typeof cursor !== "object") return void 0;
1257
+ cursor = cursor[segment];
1258
+ }
1259
+ return cursor;
1260
+ }
1261
+ /**
1262
+ * 按 extract 规则从响应 JSON 求值。导出供测试:纯函数。
1263
+ * @param rule - 提取规则(const / path / add / subtract / divide)。
1264
+ * @param data - 响应 JSON。
1265
+ * @returns 数值;取不到或结果非有限数返回 undefined。
1266
+ */
1267
+ function evalExtract(rule, data) {
1268
+ if (typeof rule.const === "number" && Number.isFinite(rule.const)) return rule.const;
1269
+ if (rule.op === "add" || rule.op === "subtract") {
1270
+ const paths = rule.paths ?? [];
1271
+ if (paths.length === 0) return void 0;
1272
+ let total;
1273
+ for (const path of paths) {
1274
+ const value = toNumber(getPath(data, path));
1275
+ if (value === void 0) return void 0;
1276
+ total = total === void 0 ? value : rule.op === "add" ? total + value : total - value;
1277
+ }
1278
+ return total;
1279
+ }
1280
+ const base = typeof rule.path === "string" ? toNumber(getPath(data, rule.path)) : void 0;
1281
+ if (base === void 0) return void 0;
1282
+ if (rule.op === "divide") {
1283
+ const by = rule.by;
1284
+ if (typeof by !== "number" || !Number.isFinite(by) || by === 0) return void 0;
1285
+ return base / by;
1286
+ }
1287
+ return base;
1288
+ }
1289
+ /** 请求头占位符解析:`{{ENV_NAME}}` 经凭据 seam 替换;解析失败返回 null。 */
1290
+ async function resolveHeaders(ctx, headers) {
1291
+ const resolved = {};
1292
+ for (const [key, value] of Object.entries(headers)) {
1293
+ const match = /^\{\{([A-Z0-9_]+)\}\}$/i.exec(value.trim());
1294
+ if (match === null) {
1295
+ resolved[key] = value;
1296
+ continue;
1297
+ }
1298
+ const hit = await ctx.credentials.resolve(credentialRef(match[1] ?? ""));
1299
+ if (hit === void 0 || hit.value === "") return null;
1300
+ resolved[key] = value.replace(match[0], hit.value);
1301
+ }
1302
+ return resolved;
1303
+ }
1304
+ /**
1305
+ * 查询自定义 Provider 余额(插件 config 的 `customBalances`)。每个条目独立
1306
+ * 成败:占位符凭据缺失 → unconfigured;401/403 → unauthorized;网络或提取
1307
+ * 失败 → unreachable。
1308
+ * @param ctx - host context carrying the credentials seam.
1309
+ * @param configs - 自定义余额配置列表。
1310
+ * @returns 每个配置一行的余额结果。
1311
+ */
1312
+ async function queryCustomBalances(ctx, configs) {
1313
+ return await Promise.all(configs.map(async (config) => {
1314
+ const provider = `custom:${config.label}`;
1315
+ const displayName = config.label;
1316
+ if (typeof config.url !== "string" || config.url === "") return {
1317
+ provider,
1318
+ displayName,
1319
+ error: "unconfigured"
1320
+ };
1321
+ const headers = await resolveHeaders(ctx, config.headers ?? {});
1322
+ if (headers === null) return {
1323
+ provider,
1324
+ displayName,
1325
+ error: "unconfigured"
1326
+ };
1327
+ const controller = new AbortController();
1328
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS$1);
1329
+ try {
1330
+ const response = await fetch(config.url, {
1331
+ method: config.method ?? "GET",
1332
+ headers: {
1333
+ accept: "application/json",
1334
+ ...headers
1335
+ },
1336
+ signal: controller.signal
1337
+ });
1338
+ if (response.status === 401 || response.status === 403) return {
1339
+ provider,
1340
+ displayName,
1341
+ error: "unauthorized"
1342
+ };
1343
+ if (!response.ok) return {
1344
+ provider,
1345
+ displayName,
1346
+ error: "unreachable"
1347
+ };
1348
+ const remaining = evalExtract(config.extract.remaining, await response.json());
1349
+ if (remaining === void 0) return {
1350
+ provider,
1351
+ displayName,
1352
+ error: "unreachable"
1353
+ };
1354
+ return {
1355
+ provider,
1356
+ displayName,
1357
+ currency: config.unit ?? "CNY",
1358
+ totalBalance: remaining
1359
+ };
1360
+ } catch {
1361
+ return {
1362
+ provider,
1363
+ displayName,
1364
+ error: "unreachable"
1365
+ };
1366
+ } finally {
1367
+ clearTimeout(timer);
1368
+ }
1369
+ }));
1370
+ }
1201
1371
  //#endregion
1202
1372
  //#region lib/types/pricing-fetch.js
1203
1373
  /**
@@ -1235,6 +1405,41 @@ const RATE_SOURCES = [{
1235
1405
  }];
1236
1406
  /** OpenRouter's public model list: per-token USD prices, no key needed. */
1237
1407
  const ROUTER_URL = "https://openrouter.ai/api/v1/models";
1408
+ /** models.dev 公开目录:pi-ai 预制提供方的上游数据源(USD / 1M tokens)。
1409
+ * 接入它即与宿主「系统设置里预制的提供方模型」对齐——预制条目来自同一份数据。 */
1410
+ const MODELS_DEV_URL = "https://models.dev/api.json";
1411
+ /**
1412
+ * models.dev provider id → 仪表盘厂商显示名。探测到的模型若其厂商显示名与此
1413
+ * 映射命中则用之;未命中的回退为探活模型自带的厂商名(系统配置里的显示名)。
1414
+ * 不作为过滤条件——只用于给 models.dev 条目补一个可读的厂商名。
1415
+ */
1416
+ const MODELS_DEV_PROVIDERS = {
1417
+ deepseek: "DeepSeek",
1418
+ zhipu: "智谱 AI",
1419
+ zhipuai: "智谱 AI",
1420
+ zai: "智谱 AI",
1421
+ qwen: "阿里通义",
1422
+ alibaba: "阿里通义",
1423
+ moonshot: "月之暗面",
1424
+ moonshotai: "月之暗面",
1425
+ volcengine: "字节豆包",
1426
+ doubao: "字节豆包",
1427
+ minimax: "MiniMax",
1428
+ baidu: "百度文心",
1429
+ tencent: "腾讯混元",
1430
+ hunyuan: "腾讯混元",
1431
+ stepfun: "阶跃星辰",
1432
+ iflytek: "科大讯飞",
1433
+ sensetime: "商汤",
1434
+ baichuan: "百川智能",
1435
+ "01.ai": "零一万物",
1436
+ openai: "OpenAI",
1437
+ google: "Google",
1438
+ xai: "xAI",
1439
+ meta: "Meta",
1440
+ anthropic: "Anthropic",
1441
+ mistral: "Mistral"
1442
+ };
1238
1443
  /**
1239
1444
  * Built-in catalog key → OpenRouter model-id candidates. Matching prefers an
1240
1445
  * exact id, then a single strong substring hit (the router id contains the
@@ -1335,19 +1540,70 @@ function buildPrices(models) {
1335
1540
  }
1336
1541
  return Object.keys(result).length > 0 ? result : void 0;
1337
1542
  }
1543
+ /** 有限正数收窄(models.dev cost 字段的 durable 边界)。 */
1544
+ function asPrice(value) {
1545
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : void 0;
1546
+ }
1547
+ /**
1548
+ * models.dev 响应 → 目录外补充条目。不再按厂商白名单过滤:凡是有有效
1549
+ * cost 的模型都纳入(探活模型可能来自任何预制厂商,白名单会漏掉)。厂商
1550
+ * 显示名优先取映射,未命中用 provider id。导出供测试:纯函数。
1551
+ * @param data - `https://models.dev/api.json` 的响应体。
1552
+ * @returns 补充条目(按 provider 顺序稳定;仅含可计价的模型)。
1553
+ */
1554
+ function buildExtraModels(data) {
1555
+ if (data === null || typeof data !== "object") return [];
1556
+ const catalogKeys = new Set([...MODEL_CATALOG.map((entry) => entry.key.toLowerCase()), ...Object.keys(MODEL_KEY_ALIASES).map((key) => MODEL_KEY_ALIASES[key]?.toLowerCase() ?? key.toLowerCase())]);
1557
+ const extras = [];
1558
+ for (const [providerId, providerDoc] of Object.entries(data)) {
1559
+ if (providerDoc === null || typeof providerDoc !== "object") continue;
1560
+ const models = providerDoc.models;
1561
+ if (models === null || typeof models !== "object") continue;
1562
+ for (const [modelId, modelDoc] of Object.entries(models)) {
1563
+ const catalogKey = (MODEL_KEY_ALIASES[modelId] ?? modelId).toLowerCase();
1564
+ if (catalogKeys.has(catalogKey)) continue;
1565
+ const key = catalogKey;
1566
+ if (modelDoc === null || typeof modelDoc !== "object") continue;
1567
+ const cost = modelDoc.cost;
1568
+ if (cost === null || typeof cost !== "object") continue;
1569
+ const input = asPrice(cost.input);
1570
+ const output = asPrice(cost.output);
1571
+ if (input === void 0 || output === void 0) continue;
1572
+ const cacheRead = asPrice(cost.cache_read) ?? input * .1;
1573
+ const name = modelDoc.name;
1574
+ extras.push({
1575
+ key,
1576
+ name: typeof name === "string" && name !== "" ? name : modelId,
1577
+ provider: MODELS_DEV_PROVIDERS[providerId.toLowerCase()] ?? providerId,
1578
+ price: {
1579
+ input,
1580
+ cacheHit: cacheRead,
1581
+ output
1582
+ }
1583
+ });
1584
+ }
1585
+ }
1586
+ return extras;
1587
+ }
1338
1588
  /**
1339
1589
  * Fetch the live pricing once at boot. Both upstreams run in parallel; a
1340
1590
  * failure in either degrades independently to the built-in value.
1341
1591
  * @returns the live pricing snapshot (builtin when everything failed).
1342
1592
  */
1343
1593
  async function fetchLivePricing() {
1344
- const [rate, models] = await Promise.all([fetchRate(), fetchRouterModels()]);
1594
+ const [rate, models, modelsDev] = await Promise.all([
1595
+ fetchRate(),
1596
+ fetchRouterModels(),
1597
+ fetchJson(MODELS_DEV_URL)
1598
+ ]);
1345
1599
  const prices = models === void 0 ? void 0 : buildPrices(models);
1346
- if (rate === void 0 && prices === void 0) return { source: "builtin" };
1600
+ const extraModels = modelsDev === null ? void 0 : buildExtraModels(modelsDev);
1601
+ if (rate === void 0 && prices === void 0 && (extraModels === void 0 || extraModels.length === 0)) return { source: "builtin" };
1347
1602
  return {
1348
1603
  source: "live",
1349
1604
  ...rate !== void 0 ? { rate } : {},
1350
- ...prices !== void 0 ? { prices } : {}
1605
+ ...prices !== void 0 ? { prices } : {},
1606
+ ...extraModels !== void 0 && extraModels.length > 0 ? { extraModels } : {}
1351
1607
  };
1352
1608
  }
1353
1609
  //#endregion
@@ -1485,8 +1741,8 @@ function kimiWindow(value, kind) {
1485
1741
  const record = value;
1486
1742
  const limit = numberOrNull(record.limit ?? record.total);
1487
1743
  const remaining = numberOrNull(record.remaining);
1488
- if (limit === null || remaining === null || limit <= 0) return null;
1489
- const usedPercent = round1(clampPercent((limit - remaining) / limit * 100) ?? 0);
1744
+ if (remaining === null) return null;
1745
+ const usedPercent = limit !== null && limit > 0 ? round1(clampPercent((limit - remaining) / limit * 100) ?? 0) : remaining > 0 ? 0 : 100;
1490
1746
  const resetsAt = toIso(record.resetTime ?? record.reset_time ?? record.resetsAt);
1491
1747
  return {
1492
1748
  kind,
@@ -1999,6 +2255,7 @@ function apply(ctx, config = {}) {
1999
2255
  let live = { source: "builtin" };
2000
2256
  const refreshPricing = async () => {
2001
2257
  live = await fetchLivePricing();
2258
+ applyLivePricing(live);
2002
2259
  };
2003
2260
  refreshPricing();
2004
2261
  ctx.effect(() => {
@@ -2025,7 +2282,8 @@ function apply(ctx, config = {}) {
2025
2282
  const providers = { ...await readPiAiProviders(ctx.settings) };
2026
2283
  if (providers["deepseek"] === void 0) providers["deepseek"] = { apiKeyEnv: config.balanceApiKeyEnv ?? DEFAULT_BALANCE_API_KEY_ENV };
2027
2284
  const balances = await queryBalances(ctx, providers);
2028
- res.end(JSON.stringify({ balances }));
2285
+ const custom = await queryCustomBalances(ctx, config.customBalances ?? []);
2286
+ res.end(JSON.stringify({ balances: [...balances, ...custom] }));
2029
2287
  }
2030
2288
  }), "usage-billing: balance route");
2031
2289
  let quotaCache = {
@@ -14,7 +14,7 @@
14
14
  * provider's key once and every surface reuses it.
15
15
  */
16
16
  import type { Context } from '@deepseek-ai/cordis';
17
- import type { ProviderBalance } from './pricing-shared.ts';
17
+ import type { CustomBalanceConfig, CustomBalanceExtract, ProviderBalance } from './pricing-shared.ts';
18
18
  /**
19
19
  * Query every configured provider's account balance. A provider is queried only
20
20
  * when its llm-pi-ai route has an `apiKeyEnv`; absent routes answer
@@ -26,4 +26,20 @@ import type { ProviderBalance } from './pricing-shared.ts';
26
26
  export declare function queryBalances(ctx: Context, providers: Readonly<Record<string, {
27
27
  apiKeyEnv?: string;
28
28
  }>>): Promise<readonly ProviderBalance[]>;
29
+ /**
30
+ * 按 extract 规则从响应 JSON 求值。导出供测试:纯函数。
31
+ * @param rule - 提取规则(const / path / add / subtract / divide)。
32
+ * @param data - 响应 JSON。
33
+ * @returns 数值;取不到或结果非有限数返回 undefined。
34
+ */
35
+ export declare function evalExtract(rule: CustomBalanceExtract, data: unknown): number | undefined;
36
+ /**
37
+ * 查询自定义 Provider 余额(插件 config 的 `customBalances`)。每个条目独立
38
+ * 成败:占位符凭据缺失 → unconfigured;401/403 → unauthorized;网络或提取
39
+ * 失败 → unreachable。
40
+ * @param ctx - host context carrying the credentials seam.
41
+ * @param configs - 自定义余额配置列表。
42
+ * @returns 每个配置一行的余额结果。
43
+ */
44
+ export declare function queryCustomBalances(ctx: Context, configs: readonly CustomBalanceConfig[]): Promise<readonly ProviderBalance[]>;
29
45
  //# sourceMappingURL=balance.d.ts.map
@@ -12,6 +12,7 @@
12
12
  import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots';
13
13
  import type { SidebarFooterActionOwnerProps } from '@deepseek-ai/dsh-client-ui-sidebar/client';
14
14
  import type { createBillingBudgetStore } from './budget-store.ts';
15
+ import { type CatalogModel } from './pricing.ts';
15
16
  import { NS, type UsageBillingKey } from './locales.ts';
16
17
  /** Model-connectivity health reported by the host model directory probe. */
17
18
  export interface ModelHealth {
@@ -27,6 +28,8 @@ export interface ModelHealth {
27
28
  okProviders: readonly string[];
28
29
  /** Display names of providers whose catalog probe failed. */
29
30
  badProviders: readonly string[];
31
+ /** 探活得到的模型清单(系统里实际配置/预制的模型;无价格,费率表据此对标)。 */
32
+ catalog?: readonly CatalogModel[];
30
33
  }
31
34
  /** 仪表盘分区 Tab id。 */
32
35
  export type DashboardTab = 'overview' | 'trends' | 'providers' | 'details' | 'pricing';
@@ -16,6 +16,8 @@ export interface BudgetPrefsState {
16
16
  tierAlertDays: Record<string, string>;
17
17
  /** 最近一次余额不足通知的日期戳(YYYY-MM-DD):余额告警同样每天最多一次。 */
18
18
  lastBalanceAlertDay: string;
19
+ /** 最近一次峰谷切换提醒的切换点时刻(毫秒):同一切换点只提醒一次。 */
20
+ lastTierSwitchAt: number;
19
21
  }
20
22
  /** 预算偏好的完整写面(组件只能经这些 action 写入);type 别名以兼容 ActionsDecl 的索引签名约束。 */
21
23
  export type BudgetPrefsActions = {
@@ -23,6 +25,7 @@ export type BudgetPrefsActions = {
23
25
  setAmount: (d: BudgetPrefsState, value: number) => void;
24
26
  markTierAlerted: (d: BudgetPrefsState, tiers: readonly number[], day: string) => void;
25
27
  markBalanceAlerted: (d: BudgetPrefsState, day: string) => void;
28
+ markTierSwitchAlerted: (d: BudgetPrefsState, at: number) => void;
26
29
  };
27
30
  /**
28
31
  * Declare the budget-preferences store handle.
@@ -9,9 +9,15 @@
9
9
  * the current session id off the framework snapshot (`useSession` parent of
10
10
  * `sessionId`) and matches `bySession` (session total) and `byTurn` (latest
11
11
  * turn cost). Rendering is a pure function of the snapshot, never a side effect.
12
+ *
13
+ * The bar also carries two ambient signals: the current peak/off-peak pricing
14
+ * tier with a switch countdown (DeepSeek time-of-day pricing), and quota chips
15
+ * for subscription plans running low (≤20% remaining), so cost pressure is
16
+ * visible without opening the dashboard.
12
17
  */
13
18
  import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots';
14
19
  import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client';
20
+ import type { UsageBillingKey } from './locales.ts';
15
21
  /** The usage-stats shape the composer bar needs (a thin slice, not the whole doc). */
16
22
  export interface LiveStats {
17
23
  bySession?: readonly {
@@ -24,6 +30,15 @@ export interface LiveStats {
24
30
  cost: number;
25
31
  }[];
26
32
  }
33
+ /** 订阅额度的薄切片(/api/billing/subscriptions 响应的行)。 */
34
+ export interface QuotaSlice {
35
+ displayName: string;
36
+ status: string;
37
+ windows: readonly {
38
+ kind: string;
39
+ remainingPercent: number;
40
+ }[];
41
+ }
27
42
  /**
28
43
  * 当前会话累计费用:bySession 里会话 id 匹配的那行;缺省为 0。
29
44
  * 导出供测试:纯函数。
@@ -41,11 +56,22 @@ export declare function sessionCostOf(stats: LiveStats | null, sessionId: string
41
56
  * @returns 最新一轮费用(人民币元)。
42
57
  */
43
58
  export declare function turnCostOf(stats: LiveStats | null, sessionId: string | undefined): number;
59
+ /**
60
+ * 低额度预警 chips:查询成功(ok)且任一窗口剩余 ≤ threshold 的套餐,
61
+ * 按剩余升序、最多 3 枚。导出供测试:纯函数。
62
+ * @param quotas - 订阅额度行切片。
63
+ * @param threshold - 剩余百分比阈值(默认 20%)。
64
+ */
65
+ export declare function lowQuotaChips(quotas: readonly QuotaSlice[], threshold?: number): readonly {
66
+ name: string;
67
+ kind: string;
68
+ pct: number;
69
+ }[];
44
70
  /** Props: the session-scope snapshot selector the framework injects. */
45
71
  export interface LiveCostBarProps {
46
72
  useSession: SnapshotSelectorHook<ConversationSnapshot>;
47
73
  /** The owning dock's locale seat (bound to the billing NS). */
48
- t: (key: 'billing.liveTurn' | 'billing.liveSession') => string;
74
+ t: (key: UsageBillingKey) => string;
49
75
  }
50
76
  /**
51
77
  * Render the live cost ticker for the current session.
@@ -1,5 +1,5 @@
1
1
  /** Locale dictionaries for the usage billing surface. */
2
- export type UsageBillingKey = 'billing.title' | 'billing.subtitle' | 'billing.cost' | 'billing.todayCost' | 'billing.monthCost' | 'billing.yearCost' | 'billing.monthProjected' | 'billing.liveTurn' | 'billing.liveSession' | 'billing.totalCost' | 'billing.calls' | 'billing.cacheHitRate' | 'billing.tokens' | 'billing.inputTokens' | 'billing.outputTokens' | 'billing.avgCost' | 'billing.trend' | 'billing.trend7d' | 'billing.trend30d' | 'billing.trendEmpty' | 'billing.budget' | 'billing.sessions' | 'billing.project' | 'billing.lastActive' | 'billing.sessionOverflow' | 'billing.budgetTierBody' | 'billing.models' | 'billing.providerBilling' | 'billing.estimated' | 'billing.actual' | 'billing.pricing' | 'billing.showPricing' | 'billing.hidePricing' | 'billing.pricePerM' | 'billing.input' | 'billing.output' | 'billing.cacheHit' | 'billing.peak' | 'billing.offPeak' | 'billing.flat' | 'billing.peakHours' | 'billing.band' | 'billing.openDashboard' | 'billing.close' | 'billing.lastUpdated' | 'billing.noData' | 'billing.todayRate' | 'billing.rateLive' | 'billing.rateBuiltin' | 'billing.balance' | 'billing.balanceUnconfigured' | 'billing.balanceUnauthorized' | 'billing.balanceUnreachable' | 'billing.uncatalogued' | 'billing.estimatedPricing' | 'billing.balanceDays' | 'billing.balanceLowBody' | 'billing.subscriptions' | 'billing.subscriptionNotConfigured' | 'billing.subscriptionUnauthorized' | 'billing.subscriptionUnavailable' | 'billing.subscriptionInvalid' | 'billing.subscriptionRateLimited' | 'billing.subscriptionSession' | 'billing.subscriptionWeekly' | 'billing.subscriptionMonthly' | 'billing.subscriptionBilling' | 'billing.subscriptionRemaining' | 'billing.subscriptionReset' | 'billing.subscriptionNoApi' | 'billing.heatmapLess' | 'billing.heatmapMore' | 'billing.currency' | 'billing.currencyCny' | 'billing.currencyUsd' | 'billing.heatmap' | 'billing.rounds' | 'billing.anomaly' | 'billing.workspaces' | 'billing.plan' | 'billing.remaining' | 'billing.unknownModel' | 'billing.model' | 'billing.currentRound' | 'billing.costAbbr' | 'billing.tabOverview' | 'billing.tabTrends' | 'billing.tabProviders' | 'billing.tabDetails' | 'billing.tabPricing' | 'billing.export' | 'billing.exportCsvDay' | 'billing.exportCsvSession' | 'billing.exportJson' | 'billing.peakShare' | 'billing.peakShareHint' | 'billing.weekCost' | 'billing.roleCost' | 'billing.roleUser' | 'billing.roleAssistant' | 'billing.roleTool' | 'billing.roleHint';
2
+ export type UsageBillingKey = 'billing.title' | 'billing.subtitle' | 'billing.cost' | 'billing.todayCost' | 'billing.monthCost' | 'billing.yearCost' | 'billing.monthProjected' | 'billing.liveTurn' | 'billing.liveSession' | 'billing.totalCost' | 'billing.calls' | 'billing.cacheHitRate' | 'billing.tokens' | 'billing.inputTokens' | 'billing.outputTokens' | 'billing.avgCost' | 'billing.trend' | 'billing.trend7d' | 'billing.trend30d' | 'billing.trendEmpty' | 'billing.budget' | 'billing.sessions' | 'billing.project' | 'billing.lastActive' | 'billing.sessionOverflow' | 'billing.budgetTierBody' | 'billing.models' | 'billing.providerBilling' | 'billing.estimated' | 'billing.actual' | 'billing.pricing' | 'billing.showPricing' | 'billing.hidePricing' | 'billing.pricePerM' | 'billing.input' | 'billing.output' | 'billing.cacheHit' | 'billing.peak' | 'billing.offPeak' | 'billing.flat' | 'billing.peakHours' | 'billing.band' | 'billing.openDashboard' | 'billing.close' | 'billing.lastUpdated' | 'billing.noData' | 'billing.todayRate' | 'billing.rateLive' | 'billing.rateBuiltin' | 'billing.balance' | 'billing.balanceUnconfigured' | 'billing.balanceUnauthorized' | 'billing.balanceUnreachable' | 'billing.uncatalogued' | 'billing.estimatedPricing' | 'billing.balanceDays' | 'billing.balanceLowBody' | 'billing.subscriptions' | 'billing.subscriptionNotConfigured' | 'billing.subscriptionUnauthorized' | 'billing.subscriptionUnavailable' | 'billing.subscriptionInvalid' | 'billing.subscriptionRateLimited' | 'billing.subscriptionSession' | 'billing.subscriptionWeekly' | 'billing.subscriptionMonthly' | 'billing.subscriptionBilling' | 'billing.subscriptionRemaining' | 'billing.subscriptionExhausted' | 'billing.subscriptionReset' | 'billing.subscriptionNoApi' | 'billing.heatmapLess' | 'billing.heatmapMore' | 'billing.currency' | 'billing.currencyCny' | 'billing.currencyUsd' | 'billing.heatmap' | 'billing.rounds' | 'billing.anomaly' | 'billing.workspaces' | 'billing.plan' | 'billing.remaining' | 'billing.unknownModel' | 'billing.model' | 'billing.currentRound' | 'billing.costAbbr' | 'billing.tabOverview' | 'billing.tabTrends' | 'billing.tabProviders' | 'billing.tabDetails' | 'billing.tabPricing' | 'billing.export' | 'billing.exportCsvDay' | 'billing.exportCsvSession' | 'billing.exportJson' | 'billing.peakShare' | 'billing.peakShareHint' | 'billing.weekCost' | 'billing.roleCost' | 'billing.roleUser' | 'billing.roleAssistant' | 'billing.roleTool' | 'billing.roleHint' | 'billing.tierPeak' | 'billing.tierOff' | 'billing.tierToPeak' | 'billing.tierToOff' | 'billing.tierAlertEnterPeak' | 'billing.tierAlertEnterOff';
3
3
  export declare const NS = "usageBilling";
4
4
  export declare const zh: Record<UsageBillingKey, string>;
5
5
  export declare const en: Record<UsageBillingKey, string>;
@@ -27,6 +27,22 @@ export declare const USD_TO_CNY = 6.79;
27
27
  * @param pricing - the `/api/billing/pricing` response.
28
28
  */
29
29
  export declare function applyLivePricing(pricing: LivePricing): void;
30
+ /**
31
+ * 注入探活得到的「系统里实际配置/预制的模型」清单(host 的 llm.models 返回
32
+ * groups[].models[],含模型 id/name,无价格)。费率表据此对标现实可用模型——
33
+ * 有价的补价(内置目录 / models.dev 补充),无价的标「未收录」。纯内存状态,
34
+ * 供 `catalogEntries()` 渲染。
35
+ */
36
+ export declare function applyLiveCatalogModels(models: readonly CatalogModel[]): void;
37
+ /** 探活模型清单条目(host 的 ModelCatalogModel 投影出需要的字段)。 */
38
+ export interface CatalogModel {
39
+ /** 模型 id(如 `deepseek-v4-flash`)。 */
40
+ id: string;
41
+ /** 显示名;缺省用 id。 */
42
+ name?: string;
43
+ /** 厂商显示名(探活 group 名)。 */
44
+ provider: string;
45
+ }
30
46
  /**
31
47
  * 当前生效的 USD → CNY 汇率及其来源:live = 启动时实时拉取成功,
32
48
  * builtin = 实时拉取失败、正在用内置默认值。
@@ -52,6 +68,30 @@ export declare function isPeakHour(beijingHour: number): boolean;
52
68
  * @param timeMs - Unix epoch 毫秒;null/undefined/NaN 视为未知。
53
69
  */
54
70
  export declare function tierAt(timeMs: number | null | undefined): PriceTierId;
71
+ /**
72
+ * 当前峰谷档位与距下次切换的时长。导出供测试:纯函数。
73
+ * @param nowMs - 当前时刻(epoch 毫秒)。
74
+ * @returns 当前档位与到下一个切换边界的毫秒数。
75
+ */
76
+ export declare function tierCountdown(nowMs: number): {
77
+ tier: PriceTierId;
78
+ nextSwitchInMs: number;
79
+ };
80
+ /**
81
+ * 峰/谷切换预告:距下次切换不足 leadMs 时返回即将进入的档位与切换时刻,
82
+ * 否则 null。导出供测试:纯函数。
83
+ * @param nowMs - 当前时刻(epoch 毫秒)。
84
+ * @param leadMs - 提前量(毫秒)。
85
+ */
86
+ export declare function upcomingTierSwitch(nowMs: number, leadMs: number): {
87
+ entering: PriceTierId;
88
+ atMs: number;
89
+ } | null;
90
+ /**
91
+ * 切换倒计时短格式:`1h23m` / `45m` / `3m`。导出供测试:纯函数。
92
+ * @param ms - 剩余毫秒数。
93
+ */
94
+ export declare function formatSwitchCountdown(ms: number): string;
55
95
  /** Usage buckets consumed by one model (counts in raw tokens). */
56
96
  export interface TokenUsageBuckets {
57
97
  /** Uncached input tokens. */
@@ -100,6 +140,8 @@ export interface ModelEntry {
100
140
  * 展示时标注以免误当正式定价;正式定价公布后移除。
101
141
  */
102
142
  estimated?: boolean;
143
+ /** 探活命中但无内置/models.dev 价:费率表标「未收录」,不参与计价。 */
144
+ uncatalogued?: boolean;
103
145
  }
104
146
  /**
105
147
  * Built-in catalog of current mainstream models as of 2026-08-16, priced from
@@ -126,6 +168,17 @@ export declare const MODEL_CATALOG: readonly ModelEntry[];
126
168
  export declare const MODEL_KEY_ALIASES: Readonly<Record<string, string>>;
127
169
  /** Lookup a model by its stats key; falls back to the generic `other` entry. */
128
170
  export declare function modelOf(key: string): ModelEntry;
171
+ /**
172
+ * 模型是否可计价:内置目录或 models.dev 补充条目命中。聚合层的计价闸门
173
+ * (目录外模型不产生费用,避免兜底档误估)。
174
+ */
175
+ export declare function isPriced(key: string): boolean;
176
+ /**
177
+ * 费率表渲染的完整目录:内置 + models.dev 补充条目 + 探活模型(无价标记)。
178
+ * 探活模型去重(按归一化 id):内置/补充已有的不再重复;无价的保留并标记
179
+ * `uncatalogued`,费率表据此显示「未收录」。
180
+ */
181
+ export declare function catalogEntries(): readonly ModelEntry[];
129
182
  /** Resolve a price-table row by its CSS variable name (theme token or fallback color). */
130
183
  export declare function resolveToken(name: string): string;
131
184
  /**
@@ -12,7 +12,7 @@
12
12
  import type { Context } from '@deepseek-ai/cordis';
13
13
  import type { CredentialProvider } from '@deepseek-ai/dsh-credentials';
14
14
  import type { SettingsProvider } from '@deepseek-ai/dsh-settings';
15
- import type { SubscriptionPlanConfig } from './pricing-shared.ts';
15
+ import type { CustomBalanceConfig, SubscriptionPlanConfig } from './pricing-shared.ts';
16
16
  import { type IdentifiedSubscriptionPlan, type SubscriptionKeys } from './subscriptions.ts';
17
17
  /** Plugin configuration. */
18
18
  export interface UsageBillingConfig {
@@ -29,6 +29,8 @@ export interface UsageBillingConfig {
29
29
  /** 余额不足告警阈值(人民币元):余额低于此值时仪表盘每天提醒一次;
30
30
  不设置则客户端按默认阈值(50 元)兜底。 */
31
31
  lowBalanceThreshold?: number;
32
+ /** 自定义 Provider 余额查询(任意 HTTP 端点 + extract 规则,适配 NewApi/LiteLLM 等)。 */
33
+ customBalances?: readonly CustomBalanceConfig[];
32
34
  }
33
35
  /** Required services: the web server, the persisted session log store, and user settings. */
34
36
  export declare const inject: string[];
@@ -7,7 +7,15 @@
7
7
  * half caches whatever succeeded and the browser dashboard falls back to the
8
8
  * catalog for the rest — a total outage answers `{ source: 'builtin' }`.
9
9
  */
10
- import type { LivePricing } from './pricing-shared.ts';
10
+ import type { ExtraModelPrice, LivePricing } from './pricing-shared.ts';
11
+ /**
12
+ * models.dev 响应 → 目录外补充条目。不再按厂商白名单过滤:凡是有有效
13
+ * cost 的模型都纳入(探活模型可能来自任何预制厂商,白名单会漏掉)。厂商
14
+ * 显示名优先取映射,未命中用 provider id。导出供测试:纯函数。
15
+ * @param data - `https://models.dev/api.json` 的响应体。
16
+ * @returns 补充条目(按 provider 顺序稳定;仅含可计价的模型)。
17
+ */
18
+ export declare function buildExtraModels(data: unknown): ExtraModelPrice[];
11
19
  /**
12
20
  * Fetch the live pricing once at boot. Both upstreams run in parallel; a
13
21
  * failure in either degrades independently to the built-in value.