@kenz1117/dsh-ui-usage-billing 0.4.2 → 0.6.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
@@ -1,6 +1,8 @@
1
1
  import { readFile, stat } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
+ import { defineTool } from "@deepseek-ai/dsh-tools";
5
+ import { writeFileAtomic } from "@deepseek-ai/dsh-atomic-write";
4
6
  import { credentialRef } from "@deepseek-ai/dsh-credentials";
5
7
  /** 当前汇率:实时覆盖优先,缺省回退内置固定值。 */
6
8
  function currentRate() {
@@ -272,7 +274,8 @@ const MODEL_CATALOG = [
272
274
  input: 4,
273
275
  cacheHit: .4,
274
276
  output: 12
275
- }
277
+ },
278
+ estimated: true
276
279
  },
277
280
  {
278
281
  key: "minimax",
@@ -356,7 +359,8 @@ const MODEL_CATALOG = [
356
359
  input: 5,
357
360
  cacheHit: .5,
358
361
  output: 10
359
- }
362
+ },
363
+ estimated: true
360
364
  },
361
365
  {
362
366
  key: "sensenova",
@@ -368,7 +372,8 @@ const MODEL_CATALOG = [
368
372
  input: 4.5,
369
373
  cacheHit: .45,
370
374
  output: 9
371
- }
375
+ },
376
+ estimated: true
372
377
  },
373
378
  {
374
379
  key: "baichuan",
@@ -594,6 +599,28 @@ function computeCostAt(entry, buckets, timeMs, peakShare = DEFAULT_PEAK_SHARE) {
594
599
  if (timeMs === null || timeMs === void 0 || !Number.isFinite(timeMs)) return computeCost(entry, buckets, peakShare);
595
600
  return priceBandCost(tierAt(timeMs) === "peak" ? entry.price : entry.price.offPeak, buckets, entry.price.currency);
596
601
  }
602
+ /**
603
+ * Format an amount with adaptive precision and the given currency symbol.
604
+ * @param amount - the amount (CNY by default; pass `usd` for dollar display).
605
+ * @param currency - display currency; default `cny`.
606
+ */
607
+ function formatMoney(amount, currency = "cny") {
608
+ const value = Number(amount);
609
+ if (!Number.isFinite(value)) return currency === "cny" ? "¥0" : "$0";
610
+ const symbol = currency === "cny" ? "¥" : "$";
611
+ if (value <= 0) return `${symbol}0`;
612
+ if (value >= 1e3) return `${symbol}${value.toFixed(0)}`;
613
+ if (value >= 10) return `${symbol}${value.toFixed(1)}`;
614
+ if (value >= .1) return `${symbol}${value.toFixed(2)}`;
615
+ return `${symbol}${value.toFixed(3)}`;
616
+ }
617
+ /** Format a large token count with B/M/K suffix. */
618
+ function formatTokens(value) {
619
+ if (value >= 1e9) return `${(value / 1e9).toFixed(2)}B`;
620
+ if (value >= 1e6) return `${(value / 1e6).toFixed(1)}M`;
621
+ if (value >= 1e3) return `${(value / 1e3).toFixed(0)}K`;
622
+ return String(value);
623
+ }
597
624
  //#endregion
598
625
  //#region lib/types/aggregate.js
599
626
  /**
@@ -672,6 +699,22 @@ function workspaceNameOf(cwd) {
672
699
  if (cwd === void 0 || cwd === "") return "—";
673
700
  return cwd.split(/[\\/]/).filter(Boolean).at(-1) ?? "—";
674
701
  }
702
+ /**
703
+ * 消息文本长度:user/tool 角色分摊输入成本的启发式依据。字符串内容取其
704
+ * 长度;内容块数组累计文本块长度;其余形状按 0 计(durable 边界收窄)。
705
+ */
706
+ function messageTextLength(message) {
707
+ if (message === null || typeof message !== "object") return 0;
708
+ const content = message.content;
709
+ if (typeof content === "string") return content.length;
710
+ if (!Array.isArray(content)) return 0;
711
+ let total = 0;
712
+ for (const block of content) {
713
+ const text = block?.text;
714
+ if (typeof text === "string") total += text.length;
715
+ }
716
+ return total;
717
+ }
675
718
  /** Get-or-create one model cell inside a usage map (avoids non-null assertions). */
676
719
  function usageCell(map, key) {
677
720
  const existing = map.get(key);
@@ -722,6 +765,12 @@ function foldSession(events, subscriptionProviders) {
722
765
  byDayModels: /* @__PURE__ */ new Map(),
723
766
  planCalls: /* @__PURE__ */ new Map(),
724
767
  turns: [],
768
+ roles: {
769
+ userChars: 0,
770
+ toolChars: 0,
771
+ inputCost: 0,
772
+ outputCost: 0
773
+ },
725
774
  lastActive: 0
726
775
  };
727
776
  let key = "other";
@@ -734,6 +783,14 @@ function foldSession(events, subscriptionProviders) {
734
783
  if (typeof title === "string" && title.length > 0) fold.title = title;
735
784
  continue;
736
785
  }
786
+ if (event.type === "user/message") {
787
+ fold.roles.userChars += messageTextLength(event.data.message);
788
+ continue;
789
+ }
790
+ if (event.type === "tool/result") {
791
+ fold.roles.toolChars += messageTextLength(event.data.message);
792
+ continue;
793
+ }
737
794
  if (event.type === "turn/start") {
738
795
  const state = turnState(turns, event.data.turn ?? -1);
739
796
  if (event.time < state.startedAt) state.startedAt = event.time;
@@ -767,12 +824,24 @@ function foldSession(events, subscriptionProviders) {
767
824
  state.output += usage.outputTokens;
768
825
  state.cacheHit += usage.cacheReadTokens ?? 0;
769
826
  state.cacheMiss += usage.inputTokens + (usage.cacheWriteTokens ?? 0);
770
- if (!subscription && MODEL_CATALOG.some((entry) => entry.key === modelKey)) state.cost += computeCostAt(modelOf(modelKey), {
771
- input: (usage.cacheReadTokens ?? 0) + usage.inputTokens + (usage.cacheWriteTokens ?? 0),
772
- cacheHit: usage.cacheReadTokens ?? 0,
773
- cacheMiss: usage.inputTokens + (usage.cacheWriteTokens ?? 0),
774
- output: usage.outputTokens
775
- }, event.time);
827
+ if (!subscription && MODEL_CATALOG.some((entry) => entry.key === modelKey)) {
828
+ const buckets = {
829
+ input: (usage.cacheReadTokens ?? 0) + usage.inputTokens + (usage.cacheWriteTokens ?? 0),
830
+ cacheHit: usage.cacheReadTokens ?? 0,
831
+ cacheMiss: usage.inputTokens + (usage.cacheWriteTokens ?? 0),
832
+ output: usage.outputTokens
833
+ };
834
+ const fullCost = computeCostAt(modelOf(modelKey), buckets, event.time);
835
+ state.cost += fullCost;
836
+ const outputCost = computeCostAt(modelOf(modelKey), {
837
+ input: 0,
838
+ cacheHit: 0,
839
+ cacheMiss: 0,
840
+ output: usage.outputTokens
841
+ }, event.time);
842
+ fold.roles.outputCost += outputCost;
843
+ fold.roles.inputCost += fullCost - outputCost;
844
+ }
776
845
  if (state.startedAt === Number.MAX_SAFE_INTEGER) state.startedAt = event.time;
777
846
  }
778
847
  fold.turns = [...turns.values()].filter((state) => state.input > 0 || state.output > 0).sort((a, b) => a.turn - b.turn).map((state) => ({
@@ -857,9 +926,19 @@ function createUsageAggregator(persistence, options = {}) {
857
926
  const sessionRows = [];
858
927
  const turnRows = [];
859
928
  const workspaceMap = /* @__PURE__ */ new Map();
929
+ const roles = {
930
+ userChars: 0,
931
+ toolChars: 0,
932
+ inputCost: 0,
933
+ outputCost: 0
934
+ };
860
935
  for (const { meta, fold } of folds) {
861
936
  const sessionId = String(meta.id);
862
937
  mergeUsageInto(total, fold.total);
938
+ roles.userChars += fold.roles.userChars;
939
+ roles.toolChars += fold.roles.toolChars;
940
+ roles.inputCost += fold.roles.inputCost;
941
+ roles.outputCost += fold.roles.outputCost;
863
942
  for (const [modelKey, cell] of fold.byModel) mergeUsageInto(usageCell(byModel, modelKey), cell);
864
943
  for (const [day, cell] of fold.byDay) mergeUsageInto(usageCell(byDay, day), cell);
865
944
  for (const [day, models] of fold.byDayModels) for (const [modelKey, cell] of models) mergeUsageInto(modelDayCell(byDayModels, day, modelKey), cell);
@@ -915,7 +994,16 @@ function createUsageAggregator(persistence, options = {}) {
915
994
  byDayModels: toModelDayRecord(byDayModels),
916
995
  bySession: sessionRows.slice(0, 100),
917
996
  byTurn: turnRows.slice(0, 200),
918
- byWorkspace: workspaces.slice(0, 100)
997
+ byWorkspace: workspaces.slice(0, 100),
998
+ byRole: (() => {
999
+ const chars = roles.userChars + roles.toolChars;
1000
+ const userShare = chars > 0 ? roles.userChars / chars : .5;
1001
+ return {
1002
+ user: roles.inputCost * userShare,
1003
+ assistant: roles.outputCost,
1004
+ tool: roles.inputCost * (1 - userShare)
1005
+ };
1006
+ })()
919
1007
  };
920
1008
  lastAt = now;
921
1009
  return lastDoc;
@@ -1673,6 +1761,8 @@ const PRICING_REFRESH_INTERVAL_MS = 360 * 60 * 1e3;
1673
1761
  const SUBSCRIPTION_CACHE_MS = 300 * 1e3;
1674
1762
  /** DeepSeek 余额查询的默认凭据引用(与 llm-deepseek 的默认引用一致)。 */
1675
1763
  const DEFAULT_BALANCE_API_KEY_ENV = "DEEPSEEK_API_KEY";
1764
+ /** 统计快照的落盘节流(毫秒):前端 30 秒轮询,快照最多每 30 秒写一次。 */
1765
+ const SNAPSHOT_INTERVAL_MS = 3e4;
1676
1766
  /** Required services: the web server, the persisted session log store, and user settings. */
1677
1767
  const inject = [
1678
1768
  "webServer",
@@ -1684,6 +1774,7 @@ const inject = [
1684
1774
  * 订阅 provider id(llm-pi-ai 设置键)→ billing 适配器 key 的映射。
1685
1775
  * 复用 dsh 既有的 llm-pi-ai provider 配置(apiKeyEnv 引用),不引入新配置面。
1686
1776
  */
1777
+ /** key 只取字符串凭据字段:zaiRegion 是区域枚举,由下方区域逻辑单独赋值。 */
1687
1778
  const SUBSCRIPTION_KEY_SOURCES = [
1688
1779
  {
1689
1780
  provider: "kimi-coding",
@@ -1755,12 +1846,156 @@ async function resolveSubscriptionKeys(settings, credentials) {
1755
1846
  function apply(ctx, config = {}) {
1756
1847
  const aggregator = createUsageAggregator(ctx.sessionPersistence, { ...config.subscriptionProviders === void 0 ? {} : { subscriptionProviders: config.subscriptionProviders } });
1757
1848
  const cwd = process.cwd();
1849
+ const snapshotPath = join(homedir(), ".dsh/.dsh-usage-stats.json");
1758
1850
  const candidates = [
1759
1851
  config.statsPath,
1760
1852
  process.env.DSH_USAGE_STATS,
1761
1853
  join(cwd, ".dsh-usage-stats.json"),
1762
- join(homedir(), ".dsh/.dsh-usage-stats.json")
1854
+ snapshotPath
1763
1855
  ].filter((path) => typeof path === "string" && path.length > 0);
1856
+ let lastSnapshotAt = 0;
1857
+ const persistSnapshot = (doc) => {
1858
+ const now = Date.now();
1859
+ if (now - lastSnapshotAt < SNAPSHOT_INTERVAL_MS) return;
1860
+ lastSnapshotAt = now;
1861
+ writeFileAtomic(snapshotPath, JSON.stringify({
1862
+ ...doc,
1863
+ _writer: {
1864
+ pid: process.pid,
1865
+ at: now
1866
+ }
1867
+ }), {
1868
+ mode: 384,
1869
+ dirMode: 448
1870
+ }).catch(() => {});
1871
+ };
1872
+ (async () => {
1873
+ try {
1874
+ const text = await readFile(snapshotPath, "utf8");
1875
+ const writer = JSON.parse(text)._writer;
1876
+ if (writer?.pid !== void 0 && writer.pid !== process.pid && writer.at !== void 0 && Date.now() - writer.at < 6e4) console.warn(`[usage-billing] 检测到另一实例(pid ${writer.pid})正在提供用量统计,双实例可能导致提醒重复。`);
1877
+ } catch {}
1878
+ })();
1879
+ ctx.inject(["tools"], (toolsCtx) => {
1880
+ toolsCtx.tools.register(defineTool({
1881
+ name: "usage_stats",
1882
+ description: "查询本机 DeepSeek Harness 的模型用量与估算费用(人民币,按官方目录价估算,非账单)。range 取值:today=今天,month=本月,session=当前会话,all=累计。",
1883
+ parameters: { range: {
1884
+ type: "string",
1885
+ enum: [
1886
+ "today",
1887
+ "month",
1888
+ "session",
1889
+ "all"
1890
+ ],
1891
+ required: true,
1892
+ description: "统计范围:today / month / session / all"
1893
+ } },
1894
+ output: {
1895
+ schema: {
1896
+ type: "object",
1897
+ additionalProperties: false,
1898
+ properties: {
1899
+ range: {
1900
+ type: "string",
1901
+ required: true
1902
+ },
1903
+ cost: {
1904
+ type: "number",
1905
+ required: true,
1906
+ description: "估算费用(人民币元)"
1907
+ },
1908
+ calls: {
1909
+ type: "number",
1910
+ required: true
1911
+ },
1912
+ input: {
1913
+ type: "number",
1914
+ required: true,
1915
+ description: "输入 tokens"
1916
+ },
1917
+ output: {
1918
+ type: "number",
1919
+ required: true,
1920
+ description: "输出 tokens"
1921
+ }
1922
+ }
1923
+ },
1924
+ render: (_args, value) => [{
1925
+ type: "text",
1926
+ text: `用量(${value.range}):估算费用 ${formatMoney(value.cost)},调用 ${value.calls} 次,输入 ${formatTokens(value.input)} tokens,输出 ${formatTokens(value.output)} tokens`
1927
+ }]
1928
+ },
1929
+ async execute(args, exec) {
1930
+ const stats = await aggregator.aggregate();
1931
+ const zero = {
1932
+ range: args.range,
1933
+ cost: 0,
1934
+ calls: 0,
1935
+ input: 0,
1936
+ output: 0
1937
+ };
1938
+ if (args.range === "all") return {
1939
+ range: args.range,
1940
+ cost: stats.total.cost,
1941
+ calls: stats.total.calls,
1942
+ input: stats.total.input,
1943
+ output: stats.total.output
1944
+ };
1945
+ if (args.range === "today") {
1946
+ const day = stats.byDay[dayStamp(Date.now())];
1947
+ return day === void 0 ? zero : {
1948
+ range: args.range,
1949
+ cost: day.cost,
1950
+ calls: day.calls,
1951
+ input: day.input,
1952
+ output: day.output
1953
+ };
1954
+ }
1955
+ if (args.range === "month") {
1956
+ const prefix = dayStamp(Date.now()).slice(0, 7);
1957
+ let cost = 0;
1958
+ let calls = 0;
1959
+ let input = 0;
1960
+ let output = 0;
1961
+ for (const [date, day] of Object.entries(stats.byDay)) {
1962
+ if (!date.startsWith(prefix)) continue;
1963
+ cost += day.cost;
1964
+ calls += day.calls;
1965
+ input += day.input;
1966
+ output += day.output;
1967
+ }
1968
+ return {
1969
+ range: args.range,
1970
+ cost,
1971
+ calls,
1972
+ input,
1973
+ output
1974
+ };
1975
+ }
1976
+ const sessionId = exec.agent?.id;
1977
+ if (sessionId === void 0) throw new Error("usage_stats 的 session 范围需要 agent 会话上下文");
1978
+ let cost = 0;
1979
+ let calls = 0;
1980
+ let input = 0;
1981
+ let output = 0;
1982
+ for (const turn of stats.byTurn ?? []) {
1983
+ if (turn.sessionId !== String(sessionId)) continue;
1984
+ cost += turn.cost;
1985
+ calls += 1;
1986
+ input += turn.input;
1987
+ output += turn.output;
1988
+ }
1989
+ return {
1990
+ range: args.range,
1991
+ cost,
1992
+ calls,
1993
+ input,
1994
+ output
1995
+ };
1996
+ }
1997
+ }));
1998
+ });
1764
1999
  let live = { source: "builtin" };
1765
2000
  const refreshPricing = async () => {
1766
2001
  live = await fetchLivePricing();
@@ -1834,10 +2069,12 @@ function apply(ctx, config = {}) {
1834
2069
  ...config.monthlyBudget === void 0 ? {} : { budget: config.monthlyBudget },
1835
2070
  ...config.lowBalanceThreshold === void 0 ? {} : { lowBalanceThreshold: config.lowBalanceThreshold }
1836
2071
  };
1837
- res.end(JSON.stringify(Object.keys(injected).length === 0 ? stats : {
2072
+ const payload = Object.keys(injected).length === 0 ? stats : {
1838
2073
  ...stats,
1839
2074
  ...injected
1840
- }));
2075
+ };
2076
+ persistSnapshot(payload);
2077
+ res.end(JSON.stringify(payload));
1841
2078
  return;
1842
2079
  } catch {}
1843
2080
  for (const candidate of candidates) try {
@@ -79,6 +79,18 @@ export interface UsageStatsDocument {
79
79
  byTurn?: TurnUsageRow[];
80
80
  /** 工作区聚合:按 cwd 末级目录归并,按费用倒序;旧快照可能缺失。 */
81
81
  byWorkspace?: WorkspaceUsageRow[];
82
+ /**
83
+ * 按角色费用归因(人民币元):助手输出成本为实测计价;输入成本按会话内
84
+ * 用户消息 / 工具结果的文本长度占比启发式摊分(日志无角色级 token 实测,
85
+ * 属估算口径,UI 需标注)。旧快照可能缺失。
86
+ */
87
+ byRole?: RoleCost;
88
+ }
89
+ /** 按角色费用归因:user / tool 为输入成本的启发式摊分,assistant 为输出成本实测。 */
90
+ export interface RoleCost {
91
+ user: number;
92
+ assistant: number;
93
+ tool: number;
82
94
  }
83
95
  /** 会话明细行:仪表盘「会话明细」面板的数据源。 */
84
96
  export interface SessionUsageRow {
@@ -141,11 +153,27 @@ interface SessionFold {
141
153
  planCalls: Map<string, number>;
142
154
  /** 每轮费用明细(按轮次号升序,不含 sessionId);sessionId 在合并时补齐。 */
143
155
  turns: SessionTurnRow[];
156
+ /** 角色归因中间量:消息文本长度(user/tool)与输入/输出成本实测拆分。 */
157
+ roles: RoleFold;
144
158
  /** 日志里最新的 session/title 文本(无标题事件时 undefined)。 */
145
159
  title?: string;
146
160
  /** 最后一个事件的时间戳(毫秒);空日志为 0。 */
147
161
  lastActive: number;
148
162
  }
163
+ /** 角色归因的会话级中间量:字符占比用于把输入成本摊到 user/tool。 */
164
+ interface RoleFold {
165
+ userChars: number;
166
+ toolChars: number;
167
+ /** 输入侧成本(缓存命中 + 未命中 + 缓存写入,人民币元)。 */
168
+ inputCost: number;
169
+ /** 输出侧成本(人民币元)。 */
170
+ outputCost: number;
171
+ }
172
+ /**
173
+ * 消息文本长度:user/tool 角色分摊输入成本的启发式依据。字符串内容取其
174
+ * 长度;内容块数组累计文本块长度;其余形状按 0 计(durable 边界收窄)。
175
+ */
176
+ export declare function messageTextLength(message: unknown): number;
149
177
  /**
150
178
  * Fold one session's events into a {@link SessionFold}. 每个 LLM 调用归属到
151
179
  * 其前置 request/header 记录的模型;同时提取最新会话标题、最后活跃时间,
@@ -8,6 +8,7 @@
8
8
  * A hover crosshair shows the day's model breakdown. No chart library — the
9
9
  * surface stays self-contained and offline.
10
10
  */
11
+ import { type CostCurrency } from './pricing.ts';
11
12
  /** One model's legend identity: key, display name, and brand color. */
12
13
  export interface TrendSeriesModel {
13
14
  /** Stats key (`byModel` key), also the `byModel` map key. */
@@ -32,9 +33,11 @@ export interface TrendPoint {
32
33
  * Render the daily stacked cost bars plus the total-calls line.
33
34
  * @param props.data - sorted daily rows (ascending date).
34
35
  * @param props.models - the model legend, in bar order.
36
+ * @param props.currency - display currency for the cost labels.
35
37
  */
36
- export declare function TrendChart({ data, models }: {
38
+ export declare function TrendChart({ data, models, currency }: {
37
39
  data: readonly TrendPoint[];
38
40
  models?: readonly TrendSeriesModel[];
41
+ currency?: CostCurrency;
39
42
  }): React.ReactNode;
40
43
  //# sourceMappingURL=TrendChart.d.ts.map
@@ -12,22 +12,33 @@
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 { NS } from './locales.ts';
15
+ import { NS, type UsageBillingKey } from './locales.ts';
16
16
  /** Model-connectivity health reported by the host model directory probe. */
17
17
  export interface ModelHealth {
18
18
  /** Whether the probe completed (false while still loading). */
19
19
  checked: boolean;
20
20
  /** True when at least one connected provider answered its model catalog. */
21
21
  available: boolean;
22
- /** Connected provider count. */
23
- providers: number;
24
- /** Provider count whose catalog probe failed. */
22
+ /** 可用模型总数:累加每个厂商成功 advertise 的模型数,而非厂商数。 */
23
+ models: number;
24
+ /** 失效厂商数(目录探测失败的厂商;失败信息不细分到模型级)。 */
25
25
  failures: number;
26
26
  /** Display names of providers that answered their model catalog (live). */
27
27
  okProviders: readonly string[];
28
28
  /** Display names of providers whose catalog probe failed. */
29
29
  badProviders: readonly string[];
30
30
  }
31
+ /** 仪表盘分区 Tab id。 */
32
+ export type DashboardTab = 'overview' | 'trends' | 'providers' | 'details' | 'pricing';
33
+ /**
34
+ * Tab 定义(顺序即渲染顺序):概览=主数字/预算/KPI/热力图,趋势=趋势图/每轮费用,
35
+ * 明细=厂商计费与订阅,统计=工作区/会话明细,费率=模型单价表。导出供测试断言
36
+ * tab 与文案 key 对齐、decor 锚点落在正确分区。
37
+ */
38
+ export declare const DASHBOARD_TABS: readonly {
39
+ id: DashboardTab;
40
+ labelKey: UsageBillingKey;
41
+ }[];
31
42
  /**
32
43
  * The dashboard's display names (中文厂商名) never equal the provider names a
33
44
  * user actually configures (deepseek, zhipu, qwen…), so the dot match also
@@ -44,6 +55,43 @@ export declare const PROVIDER_ALIASES: Readonly<Record<string, readonly string[]
44
55
  * 导出供守卫测试:短别名(mi/yi)仅允许前缀形式,防止 minimax 等误吞。
45
56
  */
46
57
  export declare function providerFromModelKey(modelKey: string): string | undefined;
58
+ /**
59
+ * 本月预计总花费:按本月已有记录的平均日消耗 × 本月天数外推;无本月记录时
60
+ * 回退为最近 7 天日均 × 本月天数;无任何记录时返回 0(调用方不展示)。
61
+ * 导出供测试:纯函数,不依赖组件。
62
+ * @param byDay - 按日费用表。
63
+ * @param monthPrefix - 本月前缀(YYYY-MM)。
64
+ * @param today - 今日日期戳(YYYY-MM-DD)。
65
+ * @returns 本月预计花费(人民币元);无数据时为 0。
66
+ */
67
+ export declare function projectMonthCost(byDay: Record<string, {
68
+ cost: number;
69
+ }>, monthPrefix: string, today: string): number;
70
+ /**
71
+ * 峰谷时段费用分摊:按每轮的起始时刻(北京时间高峰 9-12 / 14-18)把费用
72
+ * 归入高峰 / 空闲两档。导出供测试:纯函数。
73
+ * @param turns - 每轮费用行(需带 startedAt 与 cost)。
74
+ * @returns 两档费用合计(人民币元)。
75
+ */
76
+ export declare function peakOffpeakCost(turns: readonly {
77
+ startedAt: number;
78
+ cost: number;
79
+ }[]): {
80
+ peak: number;
81
+ offPeak: number;
82
+ };
83
+ /**
84
+ * 近 7 天费用序列(含今天,缺日补 0):触发卡 hover 速览的迷你柱数据源。
85
+ * 导出供测试:纯函数(日期取本地时区)。
86
+ * @param byDay - 按日费用表。
87
+ * @returns 7 个 `{ date, cost }`,最旧在前。
88
+ */
89
+ export declare function lastSevenDays(byDay: Record<string, {
90
+ cost: number;
91
+ }>): readonly {
92
+ date: string;
93
+ cost: number;
94
+ }[];
47
95
  /** 组件注入面:探活 + 计费指标写入(billing 自身写入,主题插件经服务读取)。 */
48
96
  export interface UsageBillingInjected {
49
97
  checkModels: () => Promise<ModelHealth>;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Cost-spike anomaly detection (pure): marks turns whose cost exceeds a
3
+ * rolling baseline, with attribution chips. Shared by the per-turn chart and
4
+ * any future surface; kept free of React so it unit-tests without a host.
5
+ *
6
+ * Adapted from the community dsh-usage-chart flagAnomalies semantics: the
7
+ * baseline is the previous up-to-`window` turns (rows without a cost are
8
+ * skipped, so the window counts effective rows).
9
+ */
10
+ /** 异常归因 chip:输出增长 / 上下文膨胀 / 缓存命中率下降。 */
11
+ export type AnomalyReason = 'output-growth' | 'context-bloat' | 'cache-hit-drop';
12
+ /** 一轮异常标记(按会话+轮次定位)。 */
13
+ export interface AnomalyFlag {
14
+ /** 会话 id(与数据行一致,用于定位该轮)。 */
15
+ sessionId: string;
16
+ /** 会话内轮次号。 */
17
+ turn: number;
18
+ /** 该轮成本(人民币元)。 */
19
+ cost: number;
20
+ /** 归因原因(可为空:只有突增事实、无明确归因)。 */
21
+ reasons: readonly AnomalyReason[];
22
+ }
23
+ /** 异常判定所需的每轮数据形状(TurnUsageRow 的子集)。 */
24
+ export interface AnomalyRound {
25
+ sessionId: string;
26
+ turn: number;
27
+ /** 该轮成本;未估算(订阅/未知)时可为 0——基线窗口跳过 0 成本轮。 */
28
+ cost: number;
29
+ output: number;
30
+ input: number;
31
+ cacheHit: number;
32
+ cacheMiss: number;
33
+ }
34
+ /** 异常判定调参。 */
35
+ export interface AnomalyOptions {
36
+ /** 对比窗口:取该轮之前至多 window 轮做基线。默认 6。 */
37
+ window?: number;
38
+ /** 突增阈值:成本超过基线均值 × threshold 即标记。默认 2。 */
39
+ threshold?: number;
40
+ /** 归因阈值:输出/输入超过基线均值 × 该值归因为增长。默认 1.8。 */
41
+ reasonFactor?: number;
42
+ /** 归因阈值:缓存命中率低于基线该百分点归因为下降。默认 15。 */
43
+ reasonHitDropPp?: number;
44
+ }
45
+ /**
46
+ * 标记成本异常轮次(按时间顺序传入;最近的轮次排在末尾)。
47
+ * @param rounds - 按起始时间升序的轮次序列(最早在前)。
48
+ * @param options - 窗口/阈值/归因灵敏度。
49
+ * @returns 异常标记数组(保持输入顺序)。
50
+ */
51
+ export declare function flagAnomalies(rounds: readonly AnomalyRound[], options?: AnomalyOptions): AnomalyFlag[];
52
+ //# sourceMappingURL=anomaly.d.ts.map
@@ -12,8 +12,8 @@ export interface BudgetPrefsState {
12
12
  enabled: boolean;
13
13
  /** 用户设置的月度预算(人民币元);0 = 未设置(回退到宿主默认值)。 */
14
14
  amount: number;
15
- /** 最近一次超支通知的日期戳(YYYY-MM-DD):超支通知每天最多一次,跨重启生效。 */
16
- lastAlertDay: string;
15
+ /** 各档提醒的最后通知日期戳(档位百分比字符串 → YYYY-MM-DD):每档每天最多一次。 */
16
+ tierAlertDays: Record<string, string>;
17
17
  /** 最近一次余额不足通知的日期戳(YYYY-MM-DD):余额告警同样每天最多一次。 */
18
18
  lastBalanceAlertDay: string;
19
19
  }
@@ -21,7 +21,7 @@ export interface BudgetPrefsState {
21
21
  export type BudgetPrefsActions = {
22
22
  setEnabled: (d: BudgetPrefsState, on: boolean) => void;
23
23
  setAmount: (d: BudgetPrefsState, value: number) => void;
24
- markAlerted: (d: BudgetPrefsState, day: string) => void;
24
+ markTierAlerted: (d: BudgetPrefsState, tiers: readonly number[], day: string) => void;
25
25
  markBalanceAlerted: (d: BudgetPrefsState, day: string) => void;
26
26
  };
27
27
  /**
@@ -0,0 +1,33 @@
1
+ /**
2
+ * 数据导出:把用量统计导出为 CSV / JSON 供对账。
3
+ *
4
+ * 纯函数生成文本(按日 / 按会话两个视角),DOM 下载副作用单独隔离在
5
+ * `downloadText`;金额一律人民币元(与聚合口径一致),文件名带日期范围。
6
+ */
7
+ /** 按日聚合行(与 UsageStatsDocument.byDay 的行同形)。 */
8
+ export interface DayExportRow {
9
+ calls: number;
10
+ input: number;
11
+ output: number;
12
+ cacheHit: number;
13
+ cacheMiss: number;
14
+ cost: number;
15
+ }
16
+ /** 会话导出行(与 SessionUsageRow 同形,结构化声明避免跨文件引用)。 */
17
+ export interface SessionExportRow {
18
+ id: string;
19
+ title?: string;
20
+ cwd?: string;
21
+ calls: number;
22
+ cost: number;
23
+ lastActive: number;
24
+ }
25
+ /** 按日 CSV:日期,调用,输入,输出,缓存命中,缓存未命中,费用(元)。 */
26
+ export declare function dayRowsCsv(byDay: Record<string, DayExportRow>): string;
27
+ /** 按会话 CSV:会话 id,标题,项目,调用,费用(元),最后活跃(ISO)。 */
28
+ export declare function sessionRowsCsv(rows: readonly SessionExportRow[]): string;
29
+ /** 导出文件名:带日期范围(usage-2026-08-01_2026-08-22.csv);无日期时只带前缀。 */
30
+ export declare function exportFileName(prefix: string, ext: 'csv' | 'json', dates: readonly string[]): string;
31
+ /** 触发浏览器下载(唯一 DOM 副作用;调用方在 click 手势里使用)。 */
32
+ export declare function downloadText(filename: string, text: string, mime: string): void;
33
+ //# sourceMappingURL=export.d.ts.map
@@ -0,0 +1,35 @@
1
+ /**
2
+ * UsageHeatmap: dependency-free month calendar heatmap of daily cost.
3
+ *
4
+ * Styled after an "activity map": one large rounded cell per day with the
5
+ * date number printed inside, laid out in a 7-column grid (Sunday-first).
6
+ * Week-first data layout: each week is one array element of 7 cells, so the
7
+ * grid auto-rows place them correctly without per-cell gridColumnStart hacks.
8
+ * Cell intensity is the day's cost quantized to five levels against the month
9
+ * maximum (mint-green gradient, like the reference activity map). Leading
10
+ * slots before the 1st and trailing slots after the last day carry the
11
+ * cross-month dates as gray placeholders; future days of this month render as
12
+ * gray placeholders too. Hover shows the exact date and amount.
13
+ */
14
+ import { type CostCurrency } from './pricing.ts';
15
+ /** One heatmap day. */
16
+ export interface HeatmapDay {
17
+ /** ISO date `YYYY-MM-DD` (local calendar). */
18
+ date: string;
19
+ /** Value to intensity-map (daily cost in CNY). */
20
+ value: number;
21
+ }
22
+ /**
23
+ * Render the month heatmap.
24
+ * @param props.days - daily cost rows (keys are `YYYY-MM-DD`).
25
+ * @param props.currency - display currency for the hover amount.
26
+ * @param props.now - anchor date (defaults to today); injectable for tests.
27
+ * @param props.t - locale function (used for the legend labels).
28
+ */
29
+ export declare function UsageHeatmap({ days, currency, now, t }: {
30
+ days: readonly HeatmapDay[];
31
+ currency: CostCurrency;
32
+ now?: Date;
33
+ t: (key: 'billing.costAbbr' | 'billing.noData' | 'billing.heatmapLess' | 'billing.heatmapMore') => string;
34
+ }): React.ReactNode;
35
+ //# sourceMappingURL=heatmap.d.ts.map